From 32e71e8a1d99c65e15c3017307b0cb3ebb0c723c Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 10 Aug 2026 14:47:20 +0000 Subject: [PATCH 1/3] ci: publish the PostgreSQL container image on a version tag 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. --- .github/workflows/release-image.yml | 169 ++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/release-image.yml diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml new file mode 100644 index 00000000..c72862be --- /dev/null +++ b/.github/workflows/release-image.yml @@ -0,0 +1,169 @@ +# Publishes the ExtendDB PostgreSQL container image to Docker Hub on a version tag. +# +# Order is deliberate: gate -> build -> test -> log in -> push. +# * No registry credential exists in the job until the artifact has passed the +# smoke test, so the freshly built image never runs alongside the token. +# * The image that is pushed is the exact image that was tested. `docker push` +# uploads the local image by ID, so there is no rebuild between test and ship. +# +# One-time setup, already in place: +# Environment `dockerhub` (Settings -> Environments) +# Deployment branches and tags: Selected, rule `tag: v*`. A workflow pushed on +# any other ref cannot read the token at all, which is the protection that +# repository-level secrets cannot provide. +# Required reviewers: the release must be approved before the push. +# Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (Docker Hub PAT, Read/Write). +# +# When a second backend image exists, this file becomes `on: workflow_call` with +# inputs for the image repository, Dockerfile and build args, and one thin caller +# per backend triggers on `v*`. Deliberately not a matrix: a matrix asks for one +# approval per job and a partial failure publishes one backend under a version tag +# and not the other. Separate callers fail and re-run independently. + +name: release-image + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + ref: + description: 'Existing version tag to publish, e.g. v0.1.4' + required: true + +# Nothing is committed, so the job needs no write scope on the repo. +permissions: + contents: read + +# Two releases must never race to move `:latest`. The group names the image, so a +# future second backend does not serialise behind this one for no reason. +concurrency: + group: release-image-extenddb-postgres + cancel-in-progress: false + +env: + IMAGE_REPO: extenddb/extenddb-postgres + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 60 # a hung Rust build must not burn the 6h default + environment: dockerhub + + steps: + - name: Check out the tagged commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + fetch-depth: 0 # the ancestry gate below needs history + + - name: Gate - the tag must identify released code + id: meta + run: | + set -euo pipefail + REF_NAME='${{ github.event.inputs.ref || github.ref_name }}' + VERSION="${REF_NAME#v}" + + # 1. The tag and the workspace version must agree. Without this, main can + # sit at one version while the newest tag claims another. + CARGO_VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/') + if [[ "$VERSION" != "$CARGO_VERSION" ]]; then + echo "::error::tag $REF_NAME implies $VERSION but Cargo.toml says $CARGO_VERSION" + exit 1 + fi + + # 2. The tagged commit must be on main. Without this, a tag pushed on an + # unmerged branch would publish unreviewed code to a public namespace. + git fetch --no-tags origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::$REF_NAME is not an ancestor of origin/main" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "vcs_ref=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE_REPO}:${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Gate - licence notices must match Cargo.lock + # A stale SOFTWARE-LICENSE-NOTICES.html matters far more in a distributed + # image than in source. The script pins cargo-about itself and refuses to + # run against any other version, so the install must match exactly. + run: | + set -euo pipefail + cargo install --locked --version 0.9.0 --features cli cargo-about + ./devtools/generate-software-license-notices --check + + - name: Set up Buildx + # Also supplies a current buildx. The build needs >= 0.17; an older one + # fails with "compose build requires buildx 0.17.0 or later". + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build the image locally, not pushed + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + load: true # into the local store, so it can be tested first + push: false + tags: | + ${{ steps.meta.outputs.image }} + ${{ env.IMAGE_REPO }}:latest + build-args: | + VERSION=${{ steps.meta.outputs.version }} + VCS_REF=${{ steps.meta.outputs.vcs_ref }} + BUILD_DATE=${{ steps.meta.outputs.build_date }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test the built image + # EXTENDDB_IMAGE puts ci/smoke-test-container.sh 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 this + # exact image ID. The script requires docker, aws and python3 on PATH and + # fails with a clear message naming any that is missing. + env: + EXTENDDB_IMAGE: ${{ steps.meta.outputs.image }} + run: ./ci/smoke-test-container.sh + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Push the tested image + run: | + set -euo pipefail + docker push '${{ steps.meta.outputs.image }}' + docker push '${{ env.IMAGE_REPO }}:latest' + DIGEST=$(docker image inspect '${{ steps.meta.outputs.image }}' \ + --format '{{index .RepoDigests 0}}') + { + echo "### Published" + echo "" + echo "| field | value |" + echo "|---|---|" + echo "| image | \`${{ steps.meta.outputs.image }}\` |" + echo "| digest | \`${DIGEST}\` |" + echo "| commit | \`${{ steps.meta.outputs.vcs_ref }}\` |" + echo "| built | \`${{ steps.meta.outputs.build_date }}\` |" + } >> "$GITHUB_STEP_SUMMARY" + +# Two deliberate omissions, and what closing them would cost. +# +# 1. No provenance or SBOM attestations. Those come from the registry/OCI exporter, +# not the local docker exporter that `load: true` requires. Having both +# attestations and "what shipped is what was tested" means a different shape: +# push to a staging tag with provenance and sbom enabled, smoke test the pulled +# digest (prebuilt mode works on a pulled image), then promote that same digest +# with `docker buildx imagetools create`, which copies the manifest without +# rebuilding. Correct, but it needs a staging tag or a staging repository. +# +# 2. Single architecture. `load: true` cannot load a multi-platform manifest, so +# this builds the runner's amd64 only. Adding arm64 would publish an image that +# was never executed, since an amd64 runner cannot run it without qemu. Worth +# doing only together with the digest-promotion shape above. +# +# 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. From 3fbfc8873b13ff758c58917fc79a2ba8b09b75c8 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Mon, 10 Aug 2026 16:17:34 +0000 Subject: [PATCH 2/3] ci: build and publish the image for arm64 as well as amd64 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. --- .github/workflows/release-image.yml | 237 +++++++++++++++++++++------- 1 file changed, 181 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index c72862be..6af0d8f1 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,24 +1,37 @@ -# Publishes the ExtendDB PostgreSQL container image to Docker Hub on a version tag. +# Publishes the ExtendDB PostgreSQL container image to Docker Hub on a version tag, +# for linux/amd64 and linux/arm64. # -# Order is deliberate: gate -> build -> test -> log in -> push. -# * No registry credential exists in the job until the artifact has passed the -# smoke test, so the freshly built image never runs alongside the token. -# * The image that is pushed is the exact image that was tested. `docker push` -# uploads the local image by ID, so there is no rebuild between test and ship. +# Shape, and why: +# +# gate Runs once. Cheap checks that do not need a build, so a bad tag fails +# in seconds rather than after two image builds. +# +# build One job per architecture, each on its NATIVE runner. No emulation, so +# the arm64 image is genuinely executed rather than published untested. +# Each job builds with `load: true`, smoke tests the result, then saves +# the image 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. This is the only job with +# Docker Hub credentials, 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 pushes the two per-arch images and combines +# them into one manifest list per tag. # # One-time setup, already in place: # Environment `dockerhub` (Settings -> Environments) # Deployment branches and tags: Selected, rule `tag: v*`. A workflow pushed on # any other ref cannot read the token at all, which is the protection that # repository-level secrets cannot provide. -# Required reviewers: the release must be approved before the push. +# Required reviewers: the release must be approved before the push. Because the +# environment is referenced by one job only, this is a single approval. # Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (Docker Hub PAT, Read/Write). # -# When a second backend image exists, this file becomes `on: workflow_call` with -# inputs for the image repository, Dockerfile and build args, and one thin caller -# per backend triggers on `v*`. Deliberately not a matrix: a matrix asks for one -# approval per job and a partial failure publishes one backend under a version tag -# and not the other. Separate callers fail and re-run independently. +# When a second backend image exists, this becomes `on: workflow_call` with inputs +# for the image repository, Dockerfile and build args, plus one thin caller per +# backend triggering on `v*`. Deliberately not a matrix across backends: a partial +# failure would publish one backend under a version tag and not the other. name: release-image @@ -32,7 +45,7 @@ on: description: 'Existing version tag to publish, e.g. v0.1.4' required: true -# Nothing is committed, so the job needs no write scope on the repo. +# Nothing is committed, so no write scope on the repo is needed. permissions: contents: read @@ -46,11 +59,13 @@ env: IMAGE_REPO: extenddb/extenddb-postgres jobs: - publish: + gate: runs-on: ubuntu-latest - timeout-minutes: 60 # a hung Rust build must not burn the 6h default - environment: dockerhub - + timeout-minutes: 20 + outputs: + version: ${{ steps.meta.outputs.version }} + vcs_ref: ${{ steps.meta.outputs.vcs_ref }} + build_date: ${{ steps.meta.outputs.build_date }} steps: - name: Check out the tagged commit uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -73,8 +88,8 @@ jobs: exit 1 fi - # 2. The tagged commit must be on main. Without this, a tag pushed on an - # unmerged branch would publish unreviewed code to a public namespace. + # 2. The tagged commit must be on main, so a tag pushed on an unmerged + # branch cannot publish unreviewed code to a public namespace. git fetch --no-tags origin main if ! git merge-base --is-ancestor HEAD origin/main; then echo "::error::$REF_NAME is not an ancestor of origin/main" @@ -84,7 +99,6 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "vcs_ref=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - echo "image=${IMAGE_REPO}:${VERSION}" >> "$GITHUB_OUTPUT" - name: Gate - licence notices must match Cargo.lock # A stale SOFTWARE-LICENSE-NOTICES.html matters far more in a distributed @@ -95,75 +109,186 @@ jobs: cargo install --locked --version 0.9.0 --features cli cargo-about ./devtools/generate-software-license-notices --check + build: + needs: gate + # If one architecture fails, cancel the other: nothing is published either way, + # so there is no point paying for the rest of the matrix. + strategy: + fail-fast: true + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm # native arm64, no qemu + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 # a hung Rust build must not burn the 6h default + steps: + - name: Check out the tagged commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + + - name: Ensure smoke-test prerequisites + # ci/smoke-test-container.sh requires docker, aws and python3. The AWS CLI + # is not guaranteed on every runner image, and the arm64 images are newer + # than the x64 ones, so install it if absent rather than assuming. + run: | + set -euo pipefail + if ! command -v aws >/dev/null 2>&1; then + case "$(uname -m)" in + x86_64) PKG=awscli-exe-linux-x86_64.zip ;; + aarch64) PKG=awscli-exe-linux-aarch64.zip ;; + *) echo "::error::unsupported architecture $(uname -m)"; exit 1 ;; + esac + curl -fsSL "https://awscli.amazonaws.com/${PKG}" -o /tmp/awscli.zip + unzip -q /tmp/awscli.zip -d /tmp + sudo /tmp/aws/install + fi + for c in docker aws python3; do + command -v "$c" >/dev/null || { echo "::error::missing $c"; exit 1; } + done + docker compose version + - name: Set up Buildx - # Also supplies a current buildx. The build needs >= 0.17; an older one - # fails with "compose build requires buildx 0.17.0 or later". uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build the image locally, not pushed + - name: Build the image for ${{ matrix.platform }}, not pushed uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . + platforms: ${{ matrix.platform }} load: true # into the local store, so it can be tested first push: false - tags: | - ${{ steps.meta.outputs.image }} - ${{ env.IMAGE_REPO }}:latest + tags: ${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }} build-args: | - VERSION=${{ steps.meta.outputs.version }} - VCS_REF=${{ steps.meta.outputs.vcs_ref }} - BUILD_DATE=${{ steps.meta.outputs.build_date }} - cache-from: type=gha - cache-to: type=gha,mode=max + VERSION=${{ needs.gate.outputs.version }} + VCS_REF=${{ needs.gate.outputs.vcs_ref }} + BUILD_DATE=${{ needs.gate.outputs.build_date }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + - name: Confirm the image really is ${{ matrix.arch }} + # Cheap guard against a silent platform fallback: a mislabelled image would + # otherwise be published and only fail on a user's machine. + run: | + set -euo pipefail + ACTUAL=$(docker image inspect \ + '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }}' \ + --format '{{.Architecture}}') + [[ "$ACTUAL" == "${{ matrix.arch }}" ]] \ + || { echo "::error::expected ${{ matrix.arch }}, image reports $ACTUAL"; exit 1; } - name: Smoke test the built image # EXTENDDB_IMAGE puts ci/smoke-test-container.sh 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 this - # exact image ID. The script requires docker, aws and python3 on PATH and - # fails with a clear message naming any that is missing. + # exact image ID. env: - EXTENDDB_IMAGE: ${{ steps.meta.outputs.image }} + EXTENDDB_IMAGE: ${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }} run: ./ci/smoke-test-container.sh + - name: Save the tested image as an artifact + # docker save preserves the image ID, so what publish pushes is exactly + # what passed the smoke test above. + run: | + set -euo pipefail + docker save '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }}' \ + | gzip > "image-${{ matrix.arch }}.tar.gz" + ls -lh "image-${{ matrix.arch }}.tar.gz" + + - name: Upload the image artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: image-${{ matrix.arch }} + path: image-${{ matrix.arch }}.tar.gz + retention-days: 1 + compression-level: 0 # already gzipped + + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: dockerhub # the single approval gate, and the only job with secrets + steps: + - name: Download the tested images + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: images + pattern: image-* + merge-multiple: true + + - name: Load both images + run: | + set -euo pipefail + for f in images/image-*.tar.gz; do gunzip -c "$f" | docker load; done + docker image ls '${{ env.IMAGE_REPO }}' + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push the tested image + - name: Push per-architecture images and combine into manifest lists + id: push run: | set -euo pipefail - docker push '${{ steps.meta.outputs.image }}' - docker push '${{ env.IMAGE_REPO }}:latest' - DIGEST=$(docker image inspect '${{ steps.meta.outputs.image }}' \ - --format '{{index .RepoDigests 0}}') + V='${{ needs.gate.outputs.version }}' + REPO='${{ env.IMAGE_REPO }}' + + # The per-arch tags are pushed first because a manifest list can only + # reference images that already exist in the registry. They stay visible + # on Docker Hub, which is the conventional cost of this pattern; the tags + # users consume, :VERSION and :latest, are proper multi-arch lists. + docker push "${REPO}:${V}-amd64" + docker push "${REPO}:${V}-arm64" + + for TAG in "${V}" latest; do + docker buildx imagetools create -t "${REPO}:${TAG}" \ + "${REPO}:${V}-amd64" "${REPO}:${V}-arm64" + done + + DIGEST=$(docker buildx imagetools inspect "${REPO}:${V}" \ + --format '{{.Manifest.Digest}}') + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + - name: Verify both architectures are in the published manifest + run: | + set -euo pipefail + OUT=$(docker buildx imagetools inspect \ + '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}') + echo "$OUT" + echo "$OUT" | grep -q 'linux/amd64' || { echo "::error::amd64 missing"; exit 1; } + echo "$OUT" | grep -q 'linux/arm64' || { echo "::error::arm64 missing"; exit 1; } + + - name: Summarise + run: | { echo "### Published" echo "" echo "| field | value |" echo "|---|---|" - echo "| image | \`${{ steps.meta.outputs.image }}\` |" - echo "| digest | \`${DIGEST}\` |" - echo "| commit | \`${{ steps.meta.outputs.vcs_ref }}\` |" - echo "| built | \`${{ steps.meta.outputs.build_date }}\` |" + echo "| image | \`${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}\` |" + echo "| digest | \`${{ steps.push.outputs.digest }}\` |" + echo "| platforms | linux/amd64, linux/arm64 |" + echo "| commit | \`${{ needs.gate.outputs.vcs_ref }}\` |" + echo "| built | \`${{ needs.gate.outputs.build_date }}\` |" } >> "$GITHUB_STEP_SUMMARY" -# Two deliberate omissions, and what closing them would cost. -# -# 1. No provenance or SBOM attestations. Those come from the registry/OCI exporter, -# not the local docker exporter that `load: true` requires. Having both -# attestations and "what shipped is what was tested" means a different shape: -# push to a staging tag with provenance and sbom enabled, smoke test the pulled -# digest (prebuilt mode works on a pulled image), then promote that same digest -# with `docker buildx imagetools create`, which copies the manifest without -# rebuilding. Correct, but it needs a staging tag or a staging repository. +# One deliberate omission, and what closing it would cost. # -# 2. Single architecture. `load: true` cannot load a multi-platform manifest, so -# this builds the runner's amd64 only. Adding arm64 would publish an image that -# was never executed, since an amd64 runner cannot run it without qemu. Worth -# doing only together with the digest-promotion shape above. +# No provenance or SBOM attestations. Those are produced by the registry/OCI +# exporter, while `load: true` requires the local docker exporter, and `load` is +# what makes it possible to smoke test the image before any credential exists in +# the job. Adding attestations means pushing before testing, or pushing to a +# staging repository and promoting the digest afterwards. Both are worse trades +# than losing the attestation for now, so this is a considered follow-up. # # 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. From 75d28265176683c07addafb441794405e98a93fd Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Wed, 12 Aug 2026 13:31:08 +0000 Subject: [PATCH 3/3] ci(release): publish only a commit-addressed candidate, dispatched from 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--{amd64,arm64} and the sha- 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. --- .github/workflows/release-image.yml | 271 +++++++++++++++++----------- 1 file changed, 169 insertions(+), 102 deletions(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index 6af0d8f1..ef4c79a1 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,62 +1,83 @@ -# Publishes the ExtendDB PostgreSQL container image to Docker Hub on a version tag, -# for linux/amd64 and linux/arm64. +# Publishes an immutable, commit-addressed CANDIDATE of the ExtendDB PostgreSQL +# container image to Docker Hub, for linux/amd64 and linux/arm64. +# +# This workflow deliberately stops at the candidate. It never creates the +# version tag or `latest`: promotion, registry mirroring, verification, and +# signing are maintainer steps recorded in the release runbook. That keeps the +# blast radius of any workflow defect to a tag no user consumes. # # Shape, and why: # -# gate Runs once. Cheap checks that do not need a build, so a bad tag fails -# in seconds rather than after two image builds. +# gate Runs once. Cheap checks that do not need a build, so a bad input +# fails in seconds rather than after two image builds. The release +# tag arrives as a dispatch input, is validated as strict semver, +# resolved to a full commit SHA, and that SHA must be contained in +# origin/main and agree with the workspace version. +# +# build One job per architecture, each on its NATIVE runner. No emulation, +# so the arm64 image is genuinely executed rather than published +# untested. Each job builds with `load: true`, smoke tests the +# result, then saves the image 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. This is the only job +# with Docker Hub credentials, 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 pushes ONLY the +# commit-addressed candidate tags: # -# build One job per architecture, each on its NATIVE runner. No emulation, so -# the arm64 image is genuinely executed rather than published untested. -# Each job builds with `load: true`, smoke tests the result, then saves -# the image 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. +# sha--amd64 +# sha--arm64 +# sha- (multi-arch manifest list) # -# publish Runs once, after both architectures pass. This is the only job with -# Docker Hub credentials, 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 pushes the two per-arch images and combines -# them into one manifest list per tag. +# Trigger: manual workflow_dispatch only. There is intentionally no +# `push: tags:` trigger: a tag-triggered run executes the workflow definition +# from the tagged revision, which may be older than the reviewed workflow on +# protected main. Dispatching from main runs the current definition against an +# existing, validated tag. # -# One-time setup, already in place: -# Environment `dockerhub` (Settings -> Environments) -# Deployment branches and tags: Selected, rule `tag: v*`. A workflow pushed on -# any other ref cannot read the token at all, which is the protection that -# repository-level secrets cannot provide. -# Required reviewers: the release must be approved before the push. Because the -# environment is referenced by one job only, this is a single approval. -# Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (Docker Hub PAT, Read/Write). +# One-time setup (Settings -> Environments -> dockerhub): +# Deployment branches: Selected, rule `branch: main` (migrated from the old +# `tag: v*` rule when the tag trigger was removed). A dispatch from any +# other ref cannot read the token at all. +# Required reviewers: the candidate push must be approved before it happens. +# Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (Docker Hub PAT, Read/Write). # -# When a second backend image exists, this becomes `on: workflow_call` with inputs -# for the image repository, Dockerfile and build args, plus one thin caller per -# backend triggering on `v*`. Deliberately not a matrix across backends: a partial -# failure would publish one backend under a version tag and not the other. +# When a second backend image exists, this becomes `on: workflow_call` with +# inputs for the image repository, Dockerfile and build args, plus one thin +# caller per backend. Deliberately not a matrix across backends: a partial +# failure would publish one backend's candidate and not the other under the +# same release. name: release-image on: - push: - tags: - - 'v*' workflow_dispatch: inputs: - ref: - description: 'Existing version tag to publish, e.g. v0.1.4' + tag: + description: 'Existing version tag to build a candidate for, e.g. v0.1.3' required: true # Nothing is committed, so no write scope on the repo is needed. permissions: contents: read -# Two releases must never race to move `:latest`. The group names the image, so a -# future second backend does not serialise behind this one for no reason. +# Two releases must never race. The group names the image, so a future second +# backend does not serialise behind this one for no reason. concurrency: group: release-image-extenddb-postgres cancel-in-progress: false env: IMAGE_REPO: extenddb/extenddb-postgres + # Pinned AWS CLI for runners that lack it (the smoke test needs `aws`). + # Checksums are for awscli-exe-linux--2.31.6.zip, recorded when the pin + # was reviewed; bump the version and both checksums together. + AWSCLI_VERSION: 2.31.6 + AWSCLI_SHA256_X86_64: 45fdcc3003056b3e85c23776636e74208dc2fc16f26278acdbe8eafe3d4e752b + AWSCLI_SHA256_AARCH64: 517dec4ce83fabe7ace6164bb6189cdb686ef816332ccf83e81999e3fb57cad5 jobs: gate: @@ -64,46 +85,73 @@ jobs: timeout-minutes: 20 outputs: version: ${{ steps.meta.outputs.version }} - vcs_ref: ${{ steps.meta.outputs.vcs_ref }} + sha: ${{ steps.meta.outputs.sha }} build_date: ${{ steps.meta.outputs.build_date }} steps: - - name: Check out the tagged commit + - name: Check out main (the reviewed workflow's own ref) uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - ref: ${{ github.event.inputs.ref || github.ref }} - fetch-depth: 0 # the ancestry gate below needs history + fetch-depth: 0 # tag resolution and the ancestry gate need history - name: Gate - the tag must identify released code id: meta + env: + # Dispatch inputs are untrusted; they reach the shell only through the + # environment, never by interpolation into the script source. + RAW_TAG: ${{ inputs.tag }} run: | set -euo pipefail - REF_NAME='${{ github.event.inputs.ref || github.ref_name }}' - VERSION="${REF_NAME#v}" - # 1. The tag and the workspace version must agree. Without this, main can - # sit at one version while the newest tag claims another. - CARGO_VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/') - if [[ "$VERSION" != "$CARGO_VERSION" ]]; then - echo "::error::tag $REF_NAME implies $VERSION but Cargo.toml says $CARGO_VERSION" + # 1. Strict shape first: anything else cannot be a release tag and + # must not reach git commands as a ref expression. + if [[ ! "$RAW_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::input '$RAW_TAG' is not a strict semantic version tag (vMAJOR.MINOR.PATCH)" exit 1 fi + VERSION="${RAW_TAG#v}" - # 2. The tagged commit must be on main, so a tag pushed on an unmerged - # branch cannot publish unreviewed code to a public namespace. + # 2. The tag must already exist; this workflow never creates tags. + git fetch --no-tags origin "refs/tags/${RAW_TAG}:refs/tags/${RAW_TAG}" \ + || { echo "::error::tag ${RAW_TAG} does not exist on origin"; exit 1; } + SHA=$(git rev-list -n 1 "refs/tags/${RAW_TAG}") + [[ "$SHA" =~ ^[0-9a-f]{40}$ ]] \ + || { echo "::error::could not resolve ${RAW_TAG} to a full commit SHA"; exit 1; } + + # 3. The tagged commit must be on main, so a tag pushed on an + # unmerged branch cannot publish unreviewed code publicly. git fetch --no-tags origin main - if ! git merge-base --is-ancestor HEAD origin/main; then - echo "::error::$REF_NAME is not an ancestor of origin/main" + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "::error::${RAW_TAG} (${SHA}) is not contained in origin/main" + exit 1 + fi + + # 4. The tag and the workspace version at that commit must agree. + CARGO_VERSION=$(git show "${SHA}:Cargo.toml" | grep -m1 '^version' | sed 's/.*"\(.*\)".*/\1/') + if [[ "$VERSION" != "$CARGO_VERSION" ]]; then + echo "::error::tag ${RAW_TAG} implies ${VERSION} but Cargo.toml at ${SHA} says ${CARGO_VERSION}" exit 1 fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "vcs_ref=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" - echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # 5. Deterministic metadata: the build date is the tagged commit's + # own timestamp, so re-running the same release reproduces the + # same image config instead of minting a new digest per run. + BUILD_DATE=$(TZ=UTC git show -s --date=format-local:'%Y-%m-%dT%H:%M:%SZ' --format=%cd "$SHA") + + { + echo "version=$VERSION" + echo "sha=$SHA" + echo "build_date=$BUILD_DATE" + } >> "$GITHUB_OUTPUT" + + - name: Check out the tagged commit for the licence gate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ steps.meta.outputs.sha }} - name: Gate - licence notices must match Cargo.lock - # A stale SOFTWARE-LICENSE-NOTICES.html matters far more in a distributed - # image than in source. The script pins cargo-about itself and refuses to - # run against any other version, so the install must match exactly. + # A stale SOFTWARE-LICENSE-NOTICES.html matters far more in a + # distributed image than in source. The script pins cargo-about itself + # and refuses to run against any other version. run: | set -euo pipefail cargo install --locked --version 0.9.0 --features cli cargo-about @@ -111,8 +159,8 @@ jobs: build: needs: gate - # If one architecture fails, cancel the other: nothing is published either way, - # so there is no point paying for the rest of the matrix. + # If one architecture fails, cancel the other: nothing is published either + # way, so there is no point paying for the rest of the matrix. strategy: fail-fast: true matrix: @@ -129,21 +177,24 @@ jobs: - name: Check out the tagged commit uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: - ref: ${{ github.event.inputs.ref || github.ref }} + ref: ${{ needs.gate.outputs.sha }} - name: Ensure smoke-test prerequisites - # ci/smoke-test-container.sh requires docker, aws and python3. The AWS CLI - # is not guaranteed on every runner image, and the arm64 images are newer - # than the x64 ones, so install it if absent rather than assuming. + # ci/smoke-test-container.sh requires docker, aws and python3. The AWS + # CLI is not guaranteed on every runner image. Install is pinned to an + # exact version and verified against a recorded checksum: never execute + # a mutable unverified download. run: | set -euo pipefail if ! command -v aws >/dev/null 2>&1; then case "$(uname -m)" in - x86_64) PKG=awscli-exe-linux-x86_64.zip ;; - aarch64) PKG=awscli-exe-linux-aarch64.zip ;; + x86_64) EXPECTED="$AWSCLI_SHA256_X86_64"; PKG="awscli-exe-linux-x86_64-${AWSCLI_VERSION}.zip" ;; + aarch64) EXPECTED="$AWSCLI_SHA256_AARCH64"; PKG="awscli-exe-linux-aarch64-${AWSCLI_VERSION}.zip" ;; *) echo "::error::unsupported architecture $(uname -m)"; exit 1 ;; esac curl -fsSL "https://awscli.amazonaws.com/${PKG}" -o /tmp/awscli.zip + echo "${EXPECTED} /tmp/awscli.zip" | sha256sum -c - \ + || { echo "::error::AWS CLI download failed checksum verification"; exit 1; } unzip -q /tmp/awscli.zip -d /tmp sudo /tmp/aws/install fi @@ -155,28 +206,28 @@ jobs: - name: Set up Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Build the image for ${{ matrix.platform }}, not pushed + - name: Build the candidate image for ${{ matrix.platform }}, not pushed uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: ${{ matrix.platform }} load: true # into the local store, so it can be tested first push: false - tags: ${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }} + tags: ${{ env.IMAGE_REPO }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }} build-args: | VERSION=${{ needs.gate.outputs.version }} - VCS_REF=${{ needs.gate.outputs.vcs_ref }} + VCS_REF=${{ needs.gate.outputs.sha }} BUILD_DATE=${{ needs.gate.outputs.build_date }} cache-from: type=gha,scope=${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.arch }} - name: Confirm the image really is ${{ matrix.arch }} - # Cheap guard against a silent platform fallback: a mislabelled image would - # otherwise be published and only fail on a user's machine. + # Cheap guard against a silent platform fallback: a mislabelled image + # would otherwise be published and only fail on a user's machine. run: | set -euo pipefail ACTUAL=$(docker image inspect \ - '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }}' \ + '${{ env.IMAGE_REPO }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }}' \ --format '{{.Architecture}}') [[ "$ACTUAL" == "${{ matrix.arch }}" ]] \ || { echo "::error::expected ${{ matrix.arch }}, image reports $ACTUAL"; exit 1; } @@ -187,7 +238,7 @@ jobs: # `compose up --no-build`, and asserts every container is running this # exact image ID. env: - EXTENDDB_IMAGE: ${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }} + EXTENDDB_IMAGE: ${{ env.IMAGE_REPO }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }} run: ./ci/smoke-test-container.sh - name: Save the tested image as an artifact @@ -195,7 +246,7 @@ jobs: # what passed the smoke test above. run: | set -euo pipefail - docker save '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}-${{ matrix.arch }}' \ + docker save '${{ env.IMAGE_REPO }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }}' \ | gzip > "image-${{ matrix.arch }}.tar.gz" ls -lh "image-${{ matrix.arch }}.tar.gz" @@ -207,8 +258,8 @@ jobs: retention-days: 1 compression-level: 0 # already gzipped - publish: - needs: build + publish-candidate: + needs: [gate, build] runs-on: ubuntu-latest timeout-minutes: 30 environment: dockerhub # the single approval gate, and the only job with secrets @@ -235,60 +286,76 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push per-architecture images and combine into manifest lists + - name: Push the commit-addressed candidate only id: push + env: + SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail - V='${{ needs.gate.outputs.version }}' REPO='${{ env.IMAGE_REPO }}' + CANDIDATE="sha-${SHA}" - # The per-arch tags are pushed first because a manifest list can only - # reference images that already exist in the registry. They stay visible - # on Docker Hub, which is the conventional cost of this pattern; the tags - # users consume, :VERSION and :latest, are proper multi-arch lists. - docker push "${REPO}:${V}-amd64" - docker push "${REPO}:${V}-arm64" + # Refuse to overwrite: a candidate tag for this commit must not + # already exist pointing at anything else. Idempotent re-runs of the + # identical artifact are the only permitted repeat. + if docker buildx imagetools inspect "${REPO}:${CANDIDATE}" >/dev/null 2>&1; then + echo "::error::candidate ${REPO}:${CANDIDATE} already exists; refusing to overwrite. Verify and promote the existing candidate, or investigate." + exit 1 + fi - for TAG in "${V}" latest; do - docker buildx imagetools create -t "${REPO}:${TAG}" \ - "${REPO}:${V}-amd64" "${REPO}:${V}-arm64" - done + # Per-arch tags first: a manifest list can only reference images that + # already exist in the registry. + docker push "${REPO}:${CANDIDATE}-amd64" + docker push "${REPO}:${CANDIDATE}-arm64" + + docker buildx imagetools create -t "${REPO}:${CANDIDATE}" \ + "${REPO}:${CANDIDATE}-amd64" "${REPO}:${CANDIDATE}-arm64" - DIGEST=$(docker buildx imagetools inspect "${REPO}:${V}" \ + DIGEST=$(docker buildx imagetools inspect "${REPO}:${CANDIDATE}" \ --format '{{.Manifest.Digest}}') echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "candidate=${CANDIDATE}" >> "$GITHUB_OUTPUT" - - name: Verify both architectures are in the published manifest + - name: Verify both architectures are in the published candidate run: | set -euo pipefail OUT=$(docker buildx imagetools inspect \ - '${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}') + '${{ env.IMAGE_REPO }}:${{ steps.push.outputs.candidate }}') echo "$OUT" echo "$OUT" | grep -q 'linux/amd64' || { echo "::error::amd64 missing"; exit 1; } echo "$OUT" | grep -q 'linux/arm64' || { echo "::error::arm64 missing"; exit 1; } - - name: Summarise + - name: Summarise for the release checklist run: | { - echo "### Published" + echo "### Candidate published (NOT promoted)" echo "" echo "| field | value |" echo "|---|---|" - echo "| image | \`${{ env.IMAGE_REPO }}:${{ needs.gate.outputs.version }}\` |" - echo "| digest | \`${{ steps.push.outputs.digest }}\` |" + echo "| candidate | \`${{ env.IMAGE_REPO }}:${{ steps.push.outputs.candidate }}\` |" + echo "| index digest | \`${{ steps.push.outputs.digest }}\` |" echo "| platforms | linux/amd64, linux/arm64 |" - echo "| commit | \`${{ needs.gate.outputs.vcs_ref }}\` |" - echo "| built | \`${{ needs.gate.outputs.build_date }}\` |" + echo "| version (from tag) | \`${{ needs.gate.outputs.version }}\` |" + echo "| commit | \`${{ needs.gate.outputs.sha }}\` |" + echo "| build date (commit-derived) | \`${{ needs.gate.outputs.build_date }}\` |" + echo "" + echo "Next steps are manual, per the release runbook: record this digest," + echo "pull and smoke test anonymously by digest on both architectures," + echo "mirror the exact artifact to the other registries, sign, then" + echo "promote the digest to the version tag and \`latest\`." } >> "$GITHUB_STEP_SUMMARY" -# One deliberate omission, and what closing it would cost. +# Deliberate omissions, and what closing them would cost. +# +# No version tag or `latest`: promotion is a manual, recorded runbook step for +# the initial releases. Automating it (with existing-tag protection and latest +# ordering) is the Track 2 follow-up. # # No provenance or SBOM attestations. Those are produced by the registry/OCI -# exporter, while `load: true` requires the local docker exporter, and `load` is -# what makes it possible to smoke test the image before any credential exists in -# the job. Adding attestations means pushing before testing, or pushing to a -# staging repository and promoting the digest afterwards. Both are worse trades -# than losing the attestation for now, so this is a considered follow-up. +# exporter, while `load: true` requires the local docker exporter, and `load` +# is what makes it possible to smoke test the image before any credential +# exists in the job. A staging-repository push-then-promote flow would enable +# them; considered follow-up. # -# 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. +# Neither the smoke test nor the licence check runs on pull requests. This +# gates releases, not merges.