From 583fbf3305f989d228d75fcdd1d17f3403346ec6 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Thu, 22 Oct 2026 21:44:25 +0100 Subject: [PATCH] fix(docker): build golang-migrate from go.mod in Dockerfile.dev Dockerfile.dev still downloaded the golang-migrate v4.17.0 release tarball, while Dockerfile, the Makefile and the ci/database-migration workflows all build cmd/migrate as a package of this module with -tags=pgx5 (v4.19.1 from go.mod). The dev container therefore ran an older migrate than production, linked lib/pq, and used upstream's toolchain and dependency pins. Build migrate in Dockerfile.dev the same way the Dockerfile builder stage does, after go mod download. Dockerfile.test, the file the issue originally named, no longer installs migrate at all. Add TestMigrateCLI_BuiltFromModuleWithPgx5, which reads every file that installs the migrate CLI and fails on a release-tarball download, an @version suffix, or a missing pgx5 tag. Closes #45 Co-Authored-By: Claude Opus 5.5 --- Dockerfile.dev | 30 ++--- .../migrations/migrate_cli_build_test.go | 107 ++++++++++++++++++ .../migrations/migrate_security_test.go | 5 +- 3 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 internal/database/postgres/migrations/migrate_cli_build_test.go diff --git a/Dockerfile.dev b/Dockerfile.dev index 7c8bbcd34..8ba87e7ab 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,21 +18,6 @@ RUN apk add --no-cache \ # Set shell with pipefail for safer pipe operations SHELL ["/bin/ash", "-eo", "pipefail", "-c"] -# Install golang-migrate for database migrations (architecture-aware, checksum-verified) -ARG TARGETARCH -RUN MIGRATE_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \ - if [ "$MIGRATE_ARCH" = "arm64" ]; then \ - MIGRATE_SHA256="9c95441cc430ffdac89276d14de5e2f18bfafca00796c2895490d62e3776d104"; \ - else \ - MIGRATE_SHA256="26c53c9162c9c4aaa84c47cd12455d4a9ac725befbe82850a5937b5ec1e7b8e6"; \ - fi && \ - curl -Lo migrate.tar.gz "https://github.com/golang-migrate/migrate/releases/download/v4.17.0/migrate.linux-${MIGRATE_ARCH}.tar.gz" && \ - echo "${MIGRATE_SHA256} migrate.tar.gz" | sha256sum -c - && \ - tar xvzf migrate.tar.gz && \ - mv migrate /usr/local/bin/migrate && \ - chmod +x /usr/local/bin/migrate && \ - rm migrate.tar.gz - WORKDIR /app # Copy go mod files @@ -41,6 +26,21 @@ COPY go.mod go.sum ./ # Download dependencies RUN go mod download +# Build golang-migrate from this module, exactly as the builder stage in +# Dockerfile does (see the comment there): as a package of the main module with +# no @version suffix, so the version and its dependency set come from go.mod +# rather than from an upstream release tarball, and with -tags=pgx5 so the +# binary registers the pgx5:// scheme (the one docs/DEVELOPMENT.md uses) and +# does not link lib/pq (issue #1849). Before issue #45 this image downloaded the +# v4.17.0 tarball while every other path ran the go.mod version. +RUN MIGRATE_VERSION="$(go list -m -f '{{.Version}}' github.com/golang-migrate/migrate/v4)" && \ + echo "Building golang-migrate ${MIGRATE_VERSION}" && \ + CGO_ENABLED=0 go build \ + -tags=pgx5 \ + -ldflags="-s -w -X main.Version=${MIGRATE_VERSION}" \ + -o /usr/local/bin/migrate \ + github.com/golang-migrate/migrate/v4/cmd/migrate + # Copy source code COPY . . diff --git a/internal/database/postgres/migrations/migrate_cli_build_test.go b/internal/database/postgres/migrations/migrate_cli_build_test.go new file mode 100644 index 000000000..956ea16b2 --- /dev/null +++ b/internal/database/postgres/migrations/migrate_cli_build_test.go @@ -0,0 +1,107 @@ +package migrations + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// migrateCLIBuildSources lists every file that puts a golang-migrate CLI binary +// on a machine or into an image. Each must build migrateCLIPackage as a package +// of this module with -tags=pgx5, the way Dockerfile documents: no @version +// suffix (which would resolve golang-migrate's own superseded dependency pins) +// and no upstream release tarball (which carries upstream's toolchain and +// dependency set, and the lib/pq-backed postgres driver). Dockerfile.dev was +// left on a v4.17.0 tarball after every other source moved (issue #45), so the +// dev container ran an older migrate, with lib/pq, than production. +var migrateCLIBuildSources = []string{ + "Dockerfile", + "Dockerfile.dev", + "Makefile", + ".github/workflows/ci.yml", + ".github/workflows/database-migration.yml", +} + +var ( + // migrateReleaseDownload matches a download of an upstream migrate release + // artifact, e.g. .../golang-migrate/migrate/releases/download/v4.17.0/... + migrateReleaseDownload = regexp.MustCompile(`golang-migrate/migrate/releases/download/`) + // migrateCLIBuildCmd matches a `go build` or `go install` command. + migrateCLIBuildCmd = regexp.MustCompile(`\bgo\s+(build|install)\b`) + // migrateCLIPgx5Tag matches -tags=pgx5, -tags pgx5 and -tags 'pgx5'. + migrateCLIPgx5Tag = regexp.MustCompile(`-tags[=\s]+['"]?` + migrateCLIBuildTag + `['"]?(\s|$)`) +) + +// TestMigrateCLI_BuiltFromModuleWithPgx5 guards issue #45: every source that +// installs the migrate CLI builds it from this module's go.mod with the pgx5 +// tag, so the dev image, CI, the Makefile and the shipped image all run the +// same migrate version with the same driver. +func TestMigrateCLI_BuiltFromModuleWithPgx5(t *testing.T) { + root := mainModuleRoot(t) + for _, rel := range migrateCLIBuildSources { + t.Run(rel, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join(root, rel)) + require.NoError(t, err) + + cmds := shellCommands(string(content)) + var builds int + for _, cmd := range cmds { + assert.False(t, migrateReleaseDownload.MatchString(cmd), + "%s downloads a prebuilt golang-migrate release; build %s from this module instead: %s", + rel, migrateCLIPackage, cmd) + if !migrateCLIBuildCmd.MatchString(cmd) || !strings.Contains(cmd, migrateCLIPackage) { + continue + } + builds++ + assert.NotContains(t, cmd, migrateCLIPackage+"@", + "%s pins migrate with an @version suffix, which resolves golang-migrate's own go.mod: %s", + rel, cmd) + assert.Regexp(t, migrateCLIPgx5Tag, cmd, + "%s must build migrate with -tags=%s: %s", rel, migrateCLIBuildTag, cmd) + } + assert.NotZero(t, builds, + "%s does not build %s with go build/install; update this guard if it no longer installs migrate", + rel, migrateCLIPackage) + }) + } +} + +// shellCommands returns the non-comment lines of a Dockerfile, Makefile or +// workflow with backslash continuations joined, so a multi-line RUN reads as +// one command. +func shellCommands(text string) []string { + var cmds []string + var cur strings.Builder + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "#") { + continue + } + if cont, ok := strings.CutSuffix(line, `\`); ok { + cur.WriteString(cont) + cur.WriteString(" ") + continue + } + cur.WriteString(line) + if cmd := strings.TrimSpace(cur.String()); cmd != "" { + cmds = append(cmds, cmd) + } + cur.Reset() + } + if cmd := strings.TrimSpace(cur.String()); cmd != "" { + cmds = append(cmds, cmd) + } + return cmds +} + +// TestShellCommands_JoinsContinuationsAndDropsComments pins the parser the +// guard above relies on: a regression here would make it miss a split command. +func TestShellCommands_JoinsContinuationsAndDropsComments(t *testing.T) { + text := "# go install x@v1\nRUN a && \\\n # comment inside\n go build \\\n -tags=pgx5 pkg\nCMD b\n" + assert.Equal(t, []string{"RUN a && go build -tags=pgx5 pkg", "CMD b"}, shellCommands(text)) +} diff --git a/internal/database/postgres/migrations/migrate_security_test.go b/internal/database/postgres/migrations/migrate_security_test.go index 0f5f1015a..e802c06da 100644 --- a/internal/database/postgres/migrations/migrate_security_test.go +++ b/internal/database/postgres/migrations/migrate_security_test.go @@ -207,8 +207,9 @@ const ( // // Known limit, stated so nobody reads more into it: the CLI subtest pins the // build as configured, with migrateCLIBuildTag. It asserts that build stays -// lib/pq-free; it cannot notice someone changing the tag back to `postgres` in -// the Dockerfile, which is a build-configuration change no Go test observes. +// lib/pq-free; it does not notice someone changing the tag back to `postgres` in +// the Dockerfile. TestMigrateCLI_BuiltFromModuleWithPgx5 covers that by reading +// each file that builds the CLI. func TestLibPqDriverNotRegistered(t *testing.T) { t.Run("driver not registered", func(t *testing.T) { for _, scheme := range []string{"postgres", "postgresql"} {