Skip to content

ci: publish the PostgreSQL container image for amd64 and arm64 on a version tag - #251

Open
LeeroyHannigan wants to merge 3 commits into
mainfrom
ci/publish-postgres-container-image
Open

ci: publish the PostgreSQL container image for amd64 and arm64 on a version tag#251
LeeroyHannigan wants to merge 3 commits into
mainfrom
ci/publish-postgres-container-image

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

What

Adds .github/workflows/release-image.yml, which builds, tests and publishes the
PostgreSQL container image to Docker Hub as extenddb/extenddb-postgres for
linux/amd64 and linux/arm64 when a v* tag is pushed.

Three jobs rather than one:

  • gate runs once. The version, ancestry and licence checks need no build, so a
    bad tag fails in seconds instead of after two image builds.
  • build runs one job per architecture on its native runner (ubuntu-latest
    for amd64, ubuntu-24.04-arm for arm64). No qemu, so the arm64 image is genuinely
    executed rather than published untested. Each job builds with load: true, asserts
    the image really reports the expected architecture, smoke tests it via
    EXTENDDB_IMAGE, and saves it as a workflow artifact. docker save/load
    preserves the image ID, so the artifact is bit-for-bit what was tested. These
    jobs hold no registry credentials.
  • publish runs once, after both architectures pass, and is the only job that
    references the dockerhub environment. The token therefore never exists in a job
    that is executing a freshly built image, and never exists at all unless both
    architectures passed. It also keeps this to a single approval, where putting the
    environment on a matrix job would prompt twice.

Publishing pushes the two per-architecture tags, combines them into a manifest list
for both :VERSION and :latest, then re-inspects the published tag to assert both
platforms are actually present.

Three gates run before anything is built:

  • The tag version must equal the workspace version in Cargo.toml.
  • The tagged commit must be an ancestor of origin/main, so a tag pushed on an
    unmerged branch cannot publish unreviewed code to a public namespace.
  • devtools/generate-software-license-notices --check must pass.

All six actions are pinned to commit SHAs rather than floating tags, matching the
discipline the Dockerfile already applies to its base images by digest.

Why

PR #241 landed a production-shaped PostgreSQL image and a 334-line container smoke
test, but nothing in .github/workflows referenced the Dockerfile,
ci/smoke-test-container.sh or cargo-about. Both the hardening proof and the
licence-notice currency check therefore only ran when someone remembered to run them
locally, which on a hardening change means container regressions and a stale
SOFTWARE-LICENSE-NOTICES.html could ship silently.

arm64 is included from the start rather than added later because an amd64-only image
is the wrong default for a local DynamoDB emulator: a large share of the audience
develops on Apple Silicon, where such an image runs under emulation with a
platform-mismatch warning and a real performance cost.

The version gate exists for a concrete reason: main has already sat at 0.1.3 while
the newest tag claimed 0.1.2. This turns that divergence into a build failure rather
than a silently mislabelled artifact.

Closes #

Testing done

arm64 was validated locally end to end before this was written, because adding a
platform without executing it would be a guess:

  • All three pinned base image digests are OCI indexes including linux/arm64/v8, so
    the digest pins do not lock the build to one architecture.
  • The version-pinned apt packages resolve on arm64. tini=0.19.0-1+b3 is the arm64
    candidate, so the Debian binNMU suffix is not a problem as I first suspected.
  • A full arm64 image was built under emulation (41m18s compile) and the complete
    container smoke test run against it: health, CreateTable / PutItem / GetItem,
    restart persistence, graceful SIGTERM and the migration bootstrap path all passed,
    and the trap cleanup left zero containers and zero volumes.
  • It was genuinely arm64, not a silent amd64 fallback: the image reports
    arch=arm64 and the binary inside is a 64-bit aarch64 ELF. The workflow now
    asserts this same property itself, so a future platform fallback fails the build.

Also verified:

  • The workflow parses, and secrets are referenced in the publish job only.
  • Every file it invokes exists with the executable bit where needed.
  • Each gate was written against the actual scripts: the notices script does implement
    --check and pins cargo-about to exactly 0.9.0, and the smoke test does
    support a prebuilt image via EXTENDDB_IMAGE, reading the three label values back
    off it.
  • ubuntu-24.04-arm is a standard GitHub-hosted arm64 runner, free and unlimited on
    public repositories, which this repository is.
  • All six action SHAs were resolved from the GitHub API, not copied.

Not verified:

  • The workflow itself has never run. It cannot be exercised by a pull request,
    because it triggers only on v* tags. Its first real execution is its own first
    verification, so expect an iteration or two rather than a clean first pass.
  • The local arm64 smoke test ran with PostgreSQL as native amd64, since Compose does
    not pin a platform. The ExtendDB arm64 image is validated; a fully-arm64 stack is
    what the native runner will exercise.
  • The environment approval gate and the tag: v* ref restriction are configured but
    have not been exercised by a real run.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace) — not applicable, no Rust source changed
  • Code is formatted (cargo fmt --check) — not applicable, no Rust source changed
  • Clippy is clean (cargo clippy -- -W clippy::pedantic) — not applicable, no Rust source changed
  • I have added or updated tests for new functionality — see note below
  • I have updated documentation if behavior changed — see note below
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

On tests: there is no test harness for workflow files in this repo, and the gates are
themselves the assertions. The architecture assertion and the published-manifest
check are both real tests that fail loudly.

On documentation: docker/README.md does not yet describe how images are published,
and separately its prerequisites list omits that a current Docker Compose needs
buildx 0.17 or later for this build (a local run with buildx 0.12.1 fails with
compose build requires buildx 0.17.0 or later before compiling anything). Both are
worth adding; say so and I will fold them into this PR.

ADR / RFC: n/a for an RFC, since this changes no wire protocol, Storage trait, auth
model, on-disk format or CLI surface. No ADR either, on the grounds that this adds
release plumbing rather than changing documented behaviour. If you would rather the
publish mechanism and its one deliberate omission were recorded as a decision, I will
add one.

Breaking changes

None. This adds a workflow and changes no existing behaviour, no existing workflow,
and no source.

Notes for review

One deliberate omission. There are no provenance or SBOM attestations. Those are
produced by the registry/OCI exporter, while load: true requires the local docker
exporter, and load is precisely what allows the image to be smoke tested before any
credential exists in the job. Adding attestations would mean pushing before testing,
or pushing to a staging repository and promoting the digest afterwards. Both are worse
trades than losing the attestation for now.

A known cosmetic cost. The per-architecture tags (:VERSION-amd64,
:VERSION-arm64) remain visible on Docker Hub, because a manifest list can only
reference images that already exist in the registry. The tags users consume,
:VERSION and :latest, are proper multi-arch lists.

Neither the smoke test nor the licence check runs on pull requests. This gates
releases, not merges; a PR-triggered job would stop both regressing in between.

Structured for a second backend without being generalised prematurely: the image name
is a top-level env var and the concurrency group names the image, so the future
split into a workflow_call workflow plus one thin caller per backend is mechanical.
A matrix across backends would be the wrong shape, because a partial failure would
publish one backend under a version tag and not the other.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

PR #241 landed a production-shaped PostgreSQL image and a 334-line container
smoke test, but nothing in .github/workflows referenced either, so both the
Dockerfile and ci/smoke-test-container.sh only ran when someone remembered to
run them locally. This adds the release workflow that makes them mandatory.

The step order is the substance of the change: gate, build, test, log in, push.

  * The image is built with `load: true` into the local store and NOT pushed.
    The smoke test then runs against it via EXTENDDB_IMAGE, which puts the
    script into prebuilt mode: it reads VERSION / VCS_REF / BUILD_DATE back off
    the image labels, runs `compose up --no-build`, and asserts every container
    is running that exact image ID. `docker push` then uploads the same local
    image by ID, so the artifact that is published is provably the artifact
    that passed, with no rebuild in between.

  * Login happens after the smoke test rather than before it, so no registry
    credential exists in the job while the freshly built image is executing.

Three gates run before anything is built:

  * The tag version must equal the workspace version in Cargo.toml. main has
    already been at 0.1.3 while the newest tag claimed 0.1.2; this makes that
    divergence a build failure instead of a silently mislabelled artifact.

  * The tagged commit must be an ancestor of origin/main, so a tag pushed on an
    unmerged branch cannot publish unreviewed code to a public namespace.

  * devtools/generate-software-license-notices --check must pass. A stale
    SOFTWARE-LICENSE-NOTICES.html is a compliance problem in a distributed
    image in a way it is not in source, and nothing enforced it before.

All four actions are pinned to commit SHAs rather than floating tags, matching
the discipline the Dockerfile already applies to its four base images by
digest. Each pin is the newest release within the major the workflow is written
against, so the input contracts are known rather than assumed.

Two omissions are deliberate and documented in the file. There are no
provenance or SBOM attestations, because those require the registry exporter
while `load: true` requires the local docker exporter; getting both those
attestations and the test-equals-ship guarantee means pushing to a staging tag
and promoting the digest with `imagetools create`, which needs a staging
repository. The build is also single-architecture for the same reason: an
amd64 runner cannot execute an arm64 image, so publishing one would mean
publishing something never tested.

Structured for a second backend without being generalised prematurely: the
image name is a top-level env var and the concurrency group names the image, so
the future split into a `workflow_call` workflow plus one thin caller per
backend is mechanical. A matrix is the wrong shape there, because a protected
environment prompts once per job and a partial failure publishes one backend
under a version tag and not the other.
The first version of this workflow published linux/amd64 only, which was the
wrong default for a local DynamoDB emulator: a large share of the audience
develops on Apple Silicon, where an amd64-only image runs under emulation with a
platform-mismatch warning and a real performance cost.

Nothing prevented arm64. Verified before writing this: all three pinned base
image digests are OCI indexes that include linux/arm64/v8, the Dockerfile has no
architecture-specific paths since the binary is copied out of its own build
stage, and the version-pinned apt packages resolve on arm64 (tini=0.19.0-1+b3 is
the arm64 candidate, so the binNMU suffix is not a problem). An emulated arm64
image was then built locally and the full container smoke test run against it:
41m18s compile, then healthy, CreateTable / PutItem / GetItem, restart
persistence, graceful SIGTERM and migration bootstrap all passed. The image
reports arch=arm64 and the binary inside is a 64-bit aarch64 ELF, so it was not
a silent amd64 fallback.

The workflow is restructured into three jobs rather than one:

  gate      Runs once. The version, ancestry and licence checks need no build, so
            a bad tag now fails in seconds instead of after two image builds.

  build     One job per architecture on its NATIVE runner: ubuntu-latest for
            amd64, ubuntu-24.04-arm for arm64. No qemu, so the arm64 image is
            genuinely executed rather than published untested. Each job builds
            with `load: true`, asserts the image really reports the expected
            architecture, smoke tests it, and saves it as a workflow artifact.
            `docker save`/`load` preserves the image ID, so the artifact is
            bit-for-bit what was tested. These jobs hold no credentials.

  publish   Runs once, after both architectures pass, and is the only job that
            references the `dockerhub` environment. So the token never exists in
            a job that is executing a freshly built image, and never exists at
            all unless both architectures passed. It also keeps this to a single
            approval, where an environment on a matrix job would prompt twice.

Publishing pushes the two per-architecture tags, combines them into a manifest
list for both :VERSION and :latest, and then re-inspects the published tag to
assert both platforms are actually present. The per-arch tags stay visible on
Docker Hub, which is the conventional cost of this pattern; the tags users
consume are proper multi-arch lists.

Also added a prerequisites step, because ci/smoke-test-container.sh requires the
AWS CLI and it is not guaranteed on every runner image, least of all the newer
arm64 ones. It installs the correct bundle for the host architecture rather than
assuming the tool is present.

Still no provenance or SBOM attestations. Those come from the registry exporter
while `load: true` requires the local docker exporter, and `load` is precisely
what allows the image to be smoke tested before any credential exists in the
job. Adding attestations would mean pushing before testing, or pushing to a
staging repository and promoting the digest, which are worse trades than losing
the attestation for now.
@LeeroyHannigan LeeroyHannigan changed the title ci: publish the PostgreSQL container image on a version tag ci: publish the PostgreSQL container image for amd64 and arm64 on a version tag Aug 10, 2026
@robinnsc

Copy link
Copy Markdown
Collaborator

Nice. Definitely a lot in this workflow that I think we should preserve: native amd64 and ARM64 builds, actually running both images before publication, keeping Docker Hub credentials out of the build/test jobs transferring the tested images as workflow artifacts instead of rebuilding them in the publisher, pinning Actions to full commit SHAs, and checking generated license notices.

Then I think the main decision would be how much release infrastructure do we want to build before the first public image. Id say there's two main options:

  1. Use a controlled, partly manual process for the first release.
  2. Build the more complete automated release process now.

Option 1: Controlled manual process for the initial release

For the first release, it may be reasonable to keep the automation relatively small and have maintainers perform and record some of the verification and promotion steps manually.

The workflow would still build and test the images, but it would stop after publishing an immutable release candidate rather than immediately creating 0.1.3 and latest.

A possible flow would be:

workflow builds and locally tests amd64 + ARM64
→ workflow publishes sha- candidate
→ maintainer records candidate digest
→ maintainers pull and test candidate by digest
→ maintainers mirror exact candidate to GHCR/ECR
→ maintainers verify each registry copy
→ maintainers promote exact digest to 0.1.3
→ maintainers move latest
→ release notes record all digests and results

This would require fewer changes than fully automating every registry and promotion gate, but I think there are still some minimum changes we should make to this PR.

Minimum workflow changes for the manual option

The release should be dispatched using the workflow definition from protected main, with an existing version tag such as v0.1.3 as input. I suggest avoiding direct push: tags: v* publication because that executes the workflow definition from the tagged revision, which may be older than the current protected release workflow.

The workflow should:

  • Validate the input as a strict semantic version.

  • Confirm the tag exists.

  • Resolve it to a full 40-character commit SHA.

  • Confirm the commit is on main.

  • Confirm the workspace version matches.

  • Pass the input through an environment variable instead of interpolating it directly into shell source.

  • Use deterministic metadata, including the full commit SHA and a source-derived timestamp.

  • Publish only commit-addressed candidates:

    sha--amd64
    sha--arm64
    sha-

  • Avoid creating 0.1.3 and latest in the workflow.

The current direct interpolation:

REF_NAME='${{ github.event.inputs.ref || github.ref_name }}'

should be changed because GitHub context and dispatch input values are untrusted shell input.

The current wall-clock build date should also be changed:

date -u +%Y-%m-%dT%H:%M:%SZ

Rerunning the same release currently produces a different image config and digest. A commit- or tag-derived timestamp would make the result deterministic.

Manual candidate verification

Once the candidate is published, maintainers would manually:

  1. Record the Docker Hub multiarchitecture digest.
  2. Pull anonymously by digest on amd64.
  3. Pull anonymously by digest on ARM64.
  4. Run the complete container smoke suite on both.
  5. Confirm the expected platform manifests.
  6. Record the platform, config, and layer digests.
  7. Copy the exact artifact to GHCR and ECR without rebuilding it.
  8. Repeat anonymous pull and smoke testing for each registry.

Docker Hub can remain canonical:

docker.io/extenddb/extenddb-postgres

with mirrors at:

ghcr.io/extenddb/extenddb-postgres
public.ecr.aws/extenddb/extenddb-postgres

The registry index digests may differ if a registry normalizes the manifest representation, so we should record each index digest while confirming that the underlying platform configs and layers match.

ECR does not necessarily have to block the first release. We can release Docker Hub and GHCR first and add ECR after the branded public.ecr.aws/extenddb alias is active, provided we mirror the existing artifact instead of rebuilding it.

Manual stable promotion

After the candidate has been verified, a maintainer would manually promote the exact candidate digest to 0.1.3.

The promotion procedure should:

  • Refuse to replace an existing 0.1.3 that points to a different digest.
  • Confirm the version is not older than the current latest.
  • Point 0.1.3 to the tested digest.
  • Verify that 0.1.3 resolves to that digest.
  • Point latest to the same digest.
  • Verify that latest and 0.1.3 match.

This also avoids an older manual workflow invocation moving latest backward. The existing concurrency group prevents simultaneous runs but does not enforce version ordering.

The steps and outputs should be captured in a release checklist or GitHub Release so the manual process remains auditable.

Signing under the manual option

This PR is currently unsigned. Docker Hub authentication, environment approval, and the resulting image digest are useful controls, but they are not a cryptographic publisher signature.

For the first release we could consistently publish all registry copies unsigned:

Docker Hub: unsigned
GHCR: unsigned
ECR Public: unsigned

If we do that, the release notes should explicitly state that the images are unsigned and provide immutable digests. Keyless signatures can be attached to those exact digests later without rebuilding.

Alternatively, we can sign the first release, but keyless production signing should still happen in a protected GitHub Actions job rather than on a developer machine. The manual process would become:

maintainers verify candidates
→ approve protected signing job
→ signing job signs each registry digest
→ separate verification confirms the signatures
→ maintainers promote the signed digests

We should avoid signing only one registry unless there is a deliberate documented reason.

Option 2: Build the robust automated release process now

The other option is to automate the complete process in this PR or a coordinated series of PRs.

The workflow would look approximately like:

gate
└─ build-and-local-smoke [amd64, arm64]
└─ scan-and-license-check
└─ approve-candidate-publication
└─ publish-dockerhub-candidate
├─ mirror-ghcr-candidate
└─ mirror-ecr-candidate
└─ registry-smoke [registry × architecture]
└─ approve-production-release
├─ optional sign-dockerhub
├─ optional sign-ghcr
└─ optional sign-ecr
└─ verify-signatures
└─ promote-version [registry]
└─ promote-latest [registry]
└─ final-verification

This would automate:

  • Protected-main dispatch and immutable source resolution.
  • Candidate publication.
  • Anonymous pull-by-digest tests.
  • Native amd64 and ARM64 registry smoke tests.
  • Cross-registry artifact comparisons.
  • Exact-digest promotion.
  • Existing-version protection.
  • latest ordering.
  • Optional Cosign keyless signing.
  • Independent signature verification.
  • Final release evidence.

It is more work up front, but it gives us a repeatable release mechanism for 0.1.3 and every subsequent version.

If we add signing, the workflow would use the same GitHub OIDC identity for all registries while using separate registry credentials to store each signature. The signing order would be:

candidate published
→ candidate pulled and tested by digest
→ production approval
→ exact registry digest signed
→ signature independently verified
→ stable tags promoted

Signing should not be added to the current direct-to-stable push as a final command. The image should be signed only after the exact registry candidate has passed testing and before it receives a stable tag.

Common requirements for either option

The AWS CLI installation should also be addressed under either option. The workflow currently downloads the mutable AWS CLI installer and executes it with sudo without pinning a version or verifying a checksum/signature. We should either use a reviewed runner-provided version or pin and verify the installer.

…om main

Implements the reviewed minimum changes for the controlled manual initial
release (option 1 in the review discussion) and the runbook's workflow
hardening list:

- Trigger is workflow_dispatch only. The push: tags: v* trigger is removed
  because a tag-triggered run executes the workflow definition from the
  tagged revision, which can be older than the reviewed workflow on
  protected main. The dockerhub environment's deployment rule must migrate
  from 'tag: v*' to 'branch: main' immediately after this merges.
- The tag input is strictly validated: semver shape check before it reaches
  any git command, must resolve to an existing tag, full 40-char SHA,
  contained in origin/main, workspace version match checked at that commit.
- Untrusted input hardening: the dispatch input and gate outputs reach shell
  scripts only through env vars, never by direct interpolation.
- Deterministic metadata: BUILD_DATE is the tagged commit's own timestamp
  and VCS_REF is the full commit SHA, so re-running the same release
  reproduces the same image config instead of minting a new digest.
- Candidate-only publication: pushes sha-<full-commit>-{amd64,arm64} and the
  sha-<full-commit> manifest list. No version tag, no latest. Promotion,
  mirroring, verification, and signing are manual runbook steps for the
  initial release.
- Overwrite refusal: the publish job fails if the candidate tag already
  exists in the registry.
- The AWS CLI fallback install is pinned to 2.31.6 and verified against
  recorded SHA-256 checksums for both architectures before execution.

Preserved: native per-arch runners with architecture assertion, smoke test
before any credential exists, docker save/load artifact transfer so the
pushed bytes are what was tested, full-SHA action pins, licence-notice gate,
single-approval dockerhub environment, concurrency group.
@LeeroyHannigan

Copy link
Copy Markdown
Collaborator Author

Thanks, this landed exactly where the release runbook did: option 1, controlled manual initial release. Pushed 75d2826 implementing your minimum-changes list in full:

Dispatch-only. push: tags: v* is gone; the release is dispatched from main against an existing tag, so the reviewed workflow definition always runs. The dockerhub environment rule has already been migrated from tag: v* to branch: main (safe to do pre-merge since no workflow on main ever referenced the tag rule), with prevent-self-review now enabled.

Strict input validation. Semver shape check before the input reaches any git command, tag must exist, resolves to a full 40-char SHA, ancestry-checked against origin/main, and the workspace version is read at that commit (git show SHA:Cargo.toml), not at the checkout.

Untrusted input. The dispatch input and all gate outputs reach shell only via env vars; the REF_NAME='${{ ... }}' interpolation is gone.

Deterministic metadata. BUILD_DATE is now the tagged commit's own timestamp and VCS_REF is the full SHA, so re-running the same release reproduces the same image config and digest.

Candidate-only. The publish job (renamed publish-candidate) pushes sha-<full-commit>-{amd64,arm64} plus the sha-<full-commit> manifest list, refuses to overwrite an existing candidate tag, verifies both platforms, and the step summary prints the digest plus the manual next steps (record, anonymous pull-by-digest smoke on both arches, mirror exact artifact, sign, promote). No 0.1.3, no latest anywhere.

AWS CLI. The fallback install is pinned to 2.31.6 with recorded SHA-256 checksums for both architectures, verified before execution.

On signing: agreed on not signing from a developer machine as a workflow habit, and per the runbook the first release signs each registry-specific digest via the KMS-backed procedure that was dry-run on the dev key, between candidate verification and promotion. On ECR ordering: also agreed it does not block the first release if the alias timing is awkward, mirroring the exact artifact later is fine.

Everything you flagged to preserve is untouched: native per-arch runners with the architecture assertion, smoke before any credential exists, save/load artifact transfer, full-SHA pins, licence gate, single-approval environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants