From 30d36db36e5b10e4247a91daf714fd83f0bb1388 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc Date: Sun, 14 Jun 2026 00:47:46 +0700 Subject: [PATCH 1/4] Split reload signal handling per platform SIGUSR1 and SIGUSR2 do not exist on Windows, so reload.go failed to cross-compile for windows/amd64 and windows/arm64. Move watchSignals into reload_signals_unix.go behind a unix build tag and add a no-op for the platforms without the signals. The db-channel listener and the admin API still drive reloads everywhere; only the signal path is Unix-only. This unblocks the windows targets the release build needs. --- cmd/dbrest/reload.go | 30 +++---------------------- cmd/dbrest/reload_signals_other.go | 12 ++++++++++ cmd/dbrest/reload_signals_unix.go | 35 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 cmd/dbrest/reload_signals_other.go create mode 100644 cmd/dbrest/reload_signals_unix.go diff --git a/cmd/dbrest/reload.go b/cmd/dbrest/reload.go index 09bfee3..789dce5 100644 --- a/cmd/dbrest/reload.go +++ b/cmd/dbrest/reload.go @@ -3,8 +3,9 @@ // same by keeping the HTTP frontend behind an atomic handler and rebuilding // it from the new inputs; an in-flight request keeps the snapshot it started // with. A failed reload logs and keeps serving with the previous state, the -// upstream behavior. The per-driver paths (LISTEN on db-channel, db-config, -// db-pre-config) live with each backend and are not wired here yet. +// upstream behavior. The signal handler is platform-specific and lives in +// reload_signals_unix.go (the Unix signals do not exist on Windows); the +// db-channel listener lives in watchDBChannel below. package main import ( @@ -12,10 +13,8 @@ import ( "log" "net/http" "os" - "os/signal" "sync" "sync/atomic" - "syscall" "time" "github.com/tamnd/dbrest/adminapi" @@ -203,26 +202,3 @@ func (a *app) watchDBChannel(ctx context.Context) { } }() } - -// watchSignals installs the two reload signals. Reload failures log and keep -// the previous state; they never terminate the process. -func (a *app) watchSignals() { - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2) - go func() { - for s := range ch { - switch s { - case syscall.SIGUSR1: - log.Printf("dbrest: received SIGUSR1, reloading the schema cache") - if err := a.reloadSchema(); err != nil { - log.Printf("dbrest: schema cache reload failed, keeping the old cache: %v", err) - } - case syscall.SIGUSR2: - log.Printf("dbrest: received SIGUSR2, reloading the configuration") - if err := a.reloadConfig(os.Environ()); err != nil { - log.Printf("dbrest: config reload failed, keeping the old config: %v", err) - } - } - } - }() -} diff --git a/cmd/dbrest/reload_signals_other.go b/cmd/dbrest/reload_signals_other.go new file mode 100644 index 0000000..2db3dfa --- /dev/null +++ b/cmd/dbrest/reload_signals_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package main + +import "log" + +// watchSignals is a no-op on platforms without the SIGUSR1/SIGUSR2 reload +// signals (Windows). Reloads there are driven by the db-channel listener and +// the admin API; the signal path simply is not available. +func (a *app) watchSignals() { + log.Printf("dbrest: signal-driven reload is unavailable on this platform; use the db-channel or the admin API") +} diff --git a/cmd/dbrest/reload_signals_unix.go b/cmd/dbrest/reload_signals_unix.go new file mode 100644 index 0000000..750add9 --- /dev/null +++ b/cmd/dbrest/reload_signals_unix.go @@ -0,0 +1,35 @@ +//go:build unix + +package main + +import ( + "log" + "os" + "os/signal" + "syscall" +) + +// watchSignals installs the two reload signals. Reload failures log and keep +// the previous state; they never terminate the process. SIGUSR1 and SIGUSR2 are +// Unix-only, so this handler is built only on Unix; see reload_signals_other.go +// for the no-op on the platforms that lack them. +func (a *app) watchSignals() { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2) + go func() { + for s := range ch { + switch s { + case syscall.SIGUSR1: + log.Printf("dbrest: received SIGUSR1, reloading the schema cache") + if err := a.reloadSchema(); err != nil { + log.Printf("dbrest: schema cache reload failed, keeping the old cache: %v", err) + } + case syscall.SIGUSR2: + log.Printf("dbrest: received SIGUSR2, reloading the configuration") + if err := a.reloadConfig(os.Environ()); err != nil { + log.Printf("dbrest: config reload failed, keeping the old config: %v", err) + } + } + } + }() +} From 015f7ba1f762e84e3df088fc1436873cc532ebbc Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc Date: Sun, 14 Jun 2026 00:47:51 +0700 Subject: [PATCH 2/4] Stamp version, commit, and build date into the binary Add package-level version, commit, and date vars the release pipeline sets with -ldflags -X. An unstamped build still works: versionString falls back to the module version from the build info, then to "dev". The --version verb now prints the commit and build date when they were stamped. --- cmd/dbrest/cli.go | 25 ++++++++++++++++++++++++- cmd/dbrest/main.go | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/dbrest/cli.go b/cmd/dbrest/cli.go index e6b1d4f..f7883de 100644 --- a/cmd/dbrest/cli.go +++ b/cmd/dbrest/cli.go @@ -28,14 +28,37 @@ func resolveConfigPath(flagPath string, args []string) (string, error) { return args[0], nil } -// versionString is the module version when built with one, "dev" otherwise. +// Build metadata. The release pipeline stamps these with -ldflags -X; an +// unstamped build (go build, go run) leaves the defaults and falls back to the +// module version recorded in the build info. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +// versionString is the stamped release version, the module version when built +// from a checkout that carries one, or "dev". func versionString() string { + if version != "dev" { + return version + } if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { return bi.Main.Version } return "dev" } +// versionLine is the full --version line. It adds the commit and build date +// when the binary was stamped by the release pipeline. +func versionLine() string { + v := "dbrest " + versionString() + if commit != "none" || date != "unknown" { + v += fmt.Sprintf(" (commit %s, built %s)", commit, date) + } + return v +} + // probeReady asks a running instance's admin server whether it is ready, the // --ready verb orchestrators use as a health command. A non-200 answer or an // unreachable admin server is an error, which main turns into exit status 1. diff --git a/cmd/dbrest/main.go b/cmd/dbrest/main.go index 716c0f4..d97dfda 100644 --- a/cmd/dbrest/main.go +++ b/cmd/dbrest/main.go @@ -66,7 +66,7 @@ func run() error { return err } if showVersion { - fmt.Println("dbrest " + versionString()) + fmt.Println(versionLine()) return nil } if example { From 8f4b86105fa549e032d7bbf896e01e87bd82e5b2 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc Date: Sun, 14 Jun 2026 00:47:57 +0700 Subject: [PATCH 3/4] Add GoReleaser release pipeline One tag push fans out to raw archives, deb/rpm/apk packages, a multi-arch GHCR image, a checksums file, a CycloneDX SBOM per archive, and a keyless cosign signature. The Homebrew cask and Scoop manifest self-disable until their tap and bucket tokens are set, so a tokenless release still produces every downloadable artifact and the image. The Dockerfile now copies the prebuilt binary GoReleaser stages per platform instead of compiling, so the image ships the same static binary as the archives. release.yml runs goreleaser check on every PR and push, and the full release only on a version tag. --- .github/workflows/release.yml | 85 +++++++++++++++ .gitignore | 3 + .goreleaser.yaml | 189 ++++++++++++++++++++++++++++++++++ Dockerfile | 45 +++++--- 4 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2e424c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: release + +# Pushing a version tag turns the GoReleaser config (.goreleaser.yaml) into a +# GitHub release with the archives, Linux packages (deb, rpm, apk), checksums, +# SBOMs and a cosign signature, and pushes the multi-arch container image to +# GHCR. Pull requests and pushes to main run `goreleaser check` only, so a +# config that would fail a real release is caught long before the tag. + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Fast gate on every PR and push: the config parses and is valid for the + # installed GoReleaser version. No artifacts are built here. + check: + if: github.ref_type != 'tag' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check + + # The real release, only on a version tag. + release: + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + packages: write # push the image to ghcr.io + id-token: write # keyless cosign signing + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # Build and ship the linux/arm64 image from the amd64 runner. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tools GoReleaser shells out to for signing and SBOMs. + - uses: sigstore/cosign-installer@v3 + - uses: anchore/sbom-action/download-syft@v0 + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + # Creating the release and pushing the GHCR image use the built-in + # token. The package-manager publish steps each read their own secret; + # any that is unset leaves that manager skipped (the artifact is still + # produced), so the release never fails for a tap that is not set up. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7835326..1edc2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /dbrest /cmd/dbrest/dbrest +# GoReleaser build output +dist/ + # Local databases *.sqlite *.sqlite-shm diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7c18eb4 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,189 @@ +# GoReleaser turns one tag push into everything a user might install from: raw +# archives, Linux packages (deb, rpm, apk), a multi-arch container image, and +# entries for the package managers (Homebrew, Scoop). `git tag vX.Y.Z && git +# push --tags` fans out to all of them through .github/workflows/release.yml. +# +# Publish steps that push to a repository we do not own yet (the Homebrew tap, +# the Scoop bucket) self-disable when their token is absent. A release with no +# extra secrets still produces every downloadable artifact and the container +# image; each manager lights up the moment its repository and token exist. +version: 2 + +project_name: dbrest + +before: + # Only fetch modules; never `go mod tidy` during a release, the tree is kept + # tidy in CI. + hooks: + - go mod download + +builds: + - id: dbrest + binary: dbrest + main: ./cmd/dbrest + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .CommitDate }} + mod_timestamp: "{{ .CommitTimestamp }}" + targets: + - linux_amd64 + - linux_arm64 + - linux_arm_7 + - linux_386 + - darwin_amd64 + - darwin_arm64 + - windows_amd64 + - windows_arm64 + - freebsd_amd64 + - freebsd_arm64 + +archives: + # tar.gz everywhere except a zip on Windows. + - id: default + name_template: "dbrest_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + +nfpms: + # One nfpm definition emits the deb, rpm, and apk for every Linux build. The + # package is the binary and its license; the database it serves is whatever + # the operator points db-uri at, so there are no package dependencies. + - id: linux-packages + package_name: dbrest + file_name_template: "{{ .ConventionalFileName }}" + vendor: tamnd + homepage: https://github.com/tamnd/dbrest + maintainer: Duc-Tam Nguyen + description: A PostgREST-compatible REST API for any database. + license: Apache-2.0 + formats: + - deb + - rpm + - apk + bindir: /usr/bin + section: utils + contents: + - src: ./LICENSE + dst: /usr/share/doc/dbrest/LICENSE + +dockers_v2: + # One multi-platform image built with buildx. GoReleaser stages the prebuilt + # binaries under per-platform directories in the build context and the + # Dockerfile copies the right one through $TARGETPLATFORM. + - images: + - ghcr.io/tamnd/dbrest + tags: + - "{{ .Version }}" + - latest + dockerfile: Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.title: "{{ .ProjectName }}" + org.opencontainers.image.description: "A PostgREST-compatible REST API for any database" + org.opencontainers.image.url: "https://github.com/tamnd/dbrest" + org.opencontainers.image.source: "https://github.com/tamnd/dbrest" + org.opencontainers.image.version: "{{ .Version }}" + org.opencontainers.image.revision: "{{ .FullCommit }}" + org.opencontainers.image.licenses: "Apache-2.0" + +homebrew_casks: + # Homebrew cask pushed to the tap repository. Self-disables until + # HOMEBREW_TAP_GITHUB_TOKEN (a PAT with write to tamnd/homebrew-tap) is set, + # so a tokenless release still writes the cask into dist for inspection. + - name: dbrest + repository: + owner: tamnd + name: homebrew-tap + token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}' + directory: Casks + homepage: https://github.com/tamnd/dbrest + description: A PostgREST-compatible REST API for any database + skip_upload: '{{ if envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' + commit_author: + name: Duc-Tam Nguyen + email: tamnd87@gmail.com + +scoops: + # Scoop manifest for Windows, pushed to the bucket repository. + - repository: + owner: tamnd + name: scoop-bucket + token: '{{ envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}' + homepage: https://github.com/tamnd/dbrest + description: A PostgREST-compatible REST API for any database + license: Apache-2.0 + skip_upload: '{{ if envOrDefault "SCOOP_BUCKET_GITHUB_TOKEN" "" }}false{{ else }}true{{ end }}' + commit_author: + name: Duc-Tam Nguyen + email: tamnd87@gmail.com + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +sboms: + # A CycloneDX SBOM per archive via syft; the release workflow installs it. + - id: archive + artifacts: archive + +signs: + # Keyless cosign signature over the checksum file. It runs only on a real + # release in CI, where the workflow grants the OIDC token cosign needs. + - cmd: cosign + certificate: "${artifact}.pem" + args: + - sign-blob + - "--output-certificate=${certificate}" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + output: true + +docker_signs: + - cmd: cosign + artifacts: manifests + args: + - sign + - "${artifact}@${digest}" + - "--yes" + +changelog: + sort: asc + use: github + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - Merge pull request + - Merge branch + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Other + order: 999 + +release: + github: + owner: tamnd + name: dbrest + draft: false + prerelease: auto diff --git a/Dockerfile b/Dockerfile index 19614d2..f49fee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,31 @@ -# Build the dbrest binary in a Go 1.26 builder, then copy it into a minimal -# Alpine image. The binary is statically linked (CGO_ENABLED=0) so it runs on -# any Linux base image without a matching libc. -FROM docker.io/library/golang:1.26-alpine AS builder -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /bin/dbrest ./cmd/dbrest - -FROM docker.io/library/alpine:3.21 -COPY --from=builder /bin/dbrest /usr/local/bin/dbrest -EXPOSE 3001 -ENTRYPOINT ["/usr/local/bin/dbrest"] +# Consumed by GoReleaser: it copies the already cross-compiled binary out of the +# build context rather than compiling, so the image build is fast and uses the +# same static binary every other artifact ships. +# +# GoReleaser builds one multi-platform image with buildx and stages each +# platform's binary under a $TARGETPLATFORM directory (e.g. linux/amd64/) in the +# build context, so the COPY line selects the right one through the automatic +# TARGETPLATFORM build arg. +FROM alpine:3.21 + +ARG TARGETPLATFORM + +# ca-certificates for TLS to the database; tzdata for sane timestamps. +RUN apk add --no-cache ca-certificates tzdata \ + && adduser -D -H -u 10001 dbrest + +COPY $TARGETPLATFORM/dbrest /usr/bin/dbrest + +USER dbrest + +# 3000 is the API; 3001 is the admin server (/live, /ready, /metrics) when it is +# enabled. Configure the server with a mounted config file or the DBREST_* +# environment, for example: +# +# docker run -p 3000:3000 \ +# -e DBREST_DB_BACKEND=postgres \ +# -e DBREST_DB_URI="postgres://web@db/app" \ +# ghcr.io/tamnd/dbrest +EXPOSE 3000 3001 + +ENTRYPOINT ["/usr/bin/dbrest"] From f79f3aef615de08b3d065e18821afa1bc812de9b Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc Date: Sun, 14 Jun 2026 00:48:04 +0700 Subject: [PATCH 4/4] Document install methods in the README Add an Install section covering the release archives, the GHCR image, the deb and rpm packages, go install, and what each release carries (checksums, SBOM, cosign signature). The Quick start keeps using go run for local work. --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 574865c..0ce3608 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,25 @@ Early, and built subsystem by subsystem against a complete design spec. What wor The capability model, the backend SPI, and the error envelope are in place. The PostgreSQL dialect and its version-computed capabilities have landed (`backend/postgres`), the reference oracle the conformance harness diffs against. The MySQL/MariaDB dialect has landed too (`backend/mysql`), the first real divergence from the oracle: an explicit IS NULL sort key for NULL placement, a no-conflict-target upsert with a no-op ignore, restricted CAST targets, `REGEXP_LIKE`, and MATCH/AGAINST boolean-mode full text. The SQL Server (T-SQL) dialect has landed as well (`backend/sqlserver`), the quirkiest on syntax and the closest to the oracle on the security model: bracket-quoted identifiers, named `@pN` placeholders, OFFSET/FETCH paging that injects an ORDER BY when the client gave none, a CASE NULL sort key, OUTPUT in place of RETURNING, a multi-statement upsert that the data plane drives, and CONTAINS/FREETEXT full text, with native roles, RLS, and a session-context store. Each driver data plane (Execute and introspection over a live server) is a follow-on slice, since it needs a running database to test. The MongoDB backend has landed too (`backend/mongo`), and it is the one engine that does not use the SQL compiler: it lowers a filter to a `$match` query document, a read to a `$match`/`$sort`/`$skip`/`$limit`/`$project` pipeline, casts to `$convert`, and NULLS placement to an `$addFields` sort key, with the array and range operators Unsupported and the security model emulated app-side. Its live driver data plane (`$lookup`/`$graphLookup` embedding, writes, sampling-based introspection) is the follow-on slice. Each backend joins the conformance harness by adding its fixture and a CI job, with no harness changes. +## Install + +Every release ships prebuilt binaries, Linux packages, and a container image. Grab the archive for your platform from the [releases page](https://github.com/tamnd/dbrest/releases), or pull the image: + +```sh +docker run -p 3000:3000 \ + -e DBREST_DB_BACKEND=sqlite \ + -e DBREST_DB_URI='file:/data/example.sqlite' \ + ghcr.io/tamnd/dbrest +``` + +On Debian or Red Hat, install the `.deb` or `.rpm` from the release. With the Go toolchain: + +```sh +go install github.com/tamnd/dbrest/cmd/dbrest@latest +``` + +The Homebrew tap and Scoop bucket come online as those repositories are set up. Each release also carries a checksums file, a CycloneDX SBOM per archive, and a keyless cosign signature. + ## Quick start Write a config file naming the backend and the database: