Repository navigation
release-image #18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Publishes an immutable, commit-addressed CANDIDATE of an ExtendDB backend | |
| # container image to Docker Hub, for linux/amd64 and linux/arm64. | |
| # | |
| # This workflow never creates the version tag or `latest` (promotion is | |
| # promote-image's job, separately approved), but it carries the candidate all | |
| # the way to signed-and-mirrored: publish + sign under the dockerhub | |
| # environment approval, then propagation to GHCR + ECR under the ghcr | |
| # environment approval. Any defect's blast radius stays a tag no user | |
| # consumes. | |
| # | |
| # Shape, and why: | |
| # | |
| # 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, saves the image as a workflow artifact, and fails if the | |
| # saved tarball has a fixable high or critical vulnerability in | |
| # its operating system packages. `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. The only job with | |
| # Docker Hub credentials; the token never coexists with a job | |
| # executing a freshly built image. Pushes ONLY commit-addressed | |
| # candidate tags (sha-<full-commit>{,-amd64,-arm64}), verifies the | |
| # tag ANONYMOUSLY, then signs the index digest (KMS via OIDC, | |
| # Rekor, tag-based .sig). | |
| # | |
| # propagate Second approval (ghcr environment): copies image + signature to | |
| # GHCR and ECR Public via the copy-artifact composite action — | |
| # idempotent, fail-closed, digest-asserted. GHCR writes are gated | |
| # by the ghcr environment's own reviewers; the ECR role's OIDC | |
| # trust covers both protected environments. | |
| # | |
| # 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 (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). | |
| # | |
| # The backend is a dispatch choice rather than a matrix: a partial failure | |
| # must not publish one backend's candidate and not the other under the same | |
| # release. | |
| name: release-image | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: 'Existing version tag to build a candidate for, e.g. v0.1.3' | |
| required: true | |
| image: | |
| description: 'Which backend image to build' | |
| type: choice | |
| options: | |
| - extenddb-postgres | |
| - extenddb-mongodb | |
| default: extenddb-postgres | |
| # Nothing is committed, so no write scope on the repo is needed. | |
| permissions: | |
| contents: read | |
| # 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-${{ inputs.image }} | |
| cancel-in-progress: false | |
| env: | |
| # Pinned AWS CLI for runners that lack it (the smoke test needs `aws`). | |
| # Checksums are for awscli-exe-linux-<arch>-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: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| outputs: | |
| version: ${{ steps.meta.outputs.version }} | |
| sha: ${{ steps.meta.outputs.sha }} | |
| build_date: ${{ steps.meta.outputs.build_date }} | |
| image: ${{ steps.meta.outputs.image }} | |
| image_repo: ${{ steps.meta.outputs.image_repo }} | |
| dockerfile: ${{ steps.meta.outputs.dockerfile }} | |
| license_backend: ${{ steps.meta.outputs.license_backend }} | |
| smoke_script: ${{ steps.meta.outputs.smoke_script }} | |
| smoke_env: ${{ steps.meta.outputs.smoke_env }} | |
| steps: | |
| - name: Check out main (the reviewed workflow's own ref) | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| 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 }} | |
| RAW_IMAGE: ${{ inputs.image }} | |
| run: | | |
| set -euo pipefail | |
| # 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}" | |
| case "$RAW_IMAGE" in | |
| extenddb-postgres) | |
| IMAGE_REPO='extenddb/extenddb-postgres' | |
| DOCKERFILE='Dockerfile' | |
| LICENSE_BACKEND='postgres' | |
| SMOKE_SCRIPT='./ci/smoke-test-container.sh' | |
| SMOKE_ENV='EXTENDDB_IMAGE' | |
| ;; | |
| extenddb-mongodb) | |
| IMAGE_REPO='extenddb/extenddb-mongodb' | |
| DOCKERFILE='Dockerfile.mongodb' | |
| LICENSE_BACKEND='mongodb' | |
| SMOKE_SCRIPT='./ci/smoke-test-mongodb-container.sh' | |
| SMOKE_ENV='EXTENDDB_MONGODB_IMAGE' | |
| ;; | |
| *) | |
| echo "::error::image '$RAW_IMAGE' is not in the allowlist" | |
| exit 1 | |
| ;; | |
| esac | |
| # 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 "$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 | |
| # 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" | |
| echo "image=$RAW_IMAGE" | |
| echo "image_repo=$IMAGE_REPO" | |
| echo "dockerfile=$DOCKERFILE" | |
| echo "license_backend=$LICENSE_BACKEND" | |
| echo "smoke_script=$SMOKE_SCRIPT" | |
| echo "smoke_env=$SMOKE_ENV" | |
| } >> "$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. | |
| env: | |
| LICENSE_BACKEND: ${{ steps.meta.outputs.license_backend }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$LICENSE_BACKEND" == mongodb ]] \ | |
| && ! grep -q -- '--backend' devtools/generate-software-license-notices; then | |
| echo "::error::tagged commit does not contain MongoDB image license support; use a tag created after MongoDB container support was merged" | |
| exit 1 | |
| fi | |
| cargo install --locked --version 0.9.0 --features cli cargo-about | |
| ./devtools/generate-software-license-notices --check \ | |
| --backend '${{ steps.meta.outputs.license_backend }}' | |
| 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: ${{ 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. 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) 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 | |
| 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 | |
| uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 | |
| - name: Build the candidate image for ${{ matrix.platform }}, not pushed | |
| uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 | |
| with: | |
| context: . | |
| file: ${{ needs.gate.outputs.dockerfile }} | |
| platforms: ${{ matrix.platform }} | |
| load: true # into the local store, so it can be tested first | |
| push: false | |
| tags: ${{ needs.gate.outputs.image_repo }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }} | |
| build-args: | | |
| VERSION=${{ needs.gate.outputs.version }} | |
| 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. | |
| run: | | |
| set -euo pipefail | |
| ACTUAL=$(docker image inspect \ | |
| '${{ needs.gate.outputs.image_repo }}:sha-${{ needs.gate.outputs.sha }}-${{ 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. | |
| env: | |
| IMAGE: ${{ needs.gate.outputs.image_repo }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }} | |
| SMOKE_ENV: ${{ needs.gate.outputs.smoke_env }} | |
| SMOKE_SCRIPT: ${{ needs.gate.outputs.smoke_script }} | |
| run: | | |
| set -euo pipefail | |
| if [[ "$SMOKE_ENV" == 'EXTENDDB_MONGODB_IMAGE' ]]; then | |
| EXTENDDB_MONGODB_IMAGE="$IMAGE" "$SMOKE_SCRIPT" | |
| else | |
| EXTENDDB_IMAGE="$IMAGE" "$SMOKE_SCRIPT" | |
| fi | |
| - 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 '${{ needs.gate.outputs.image_repo }}:sha-${{ needs.gate.outputs.sha }}-${{ matrix.arch }}' \ | |
| | gzip > "image-${{ matrix.arch }}.tar.gz" | |
| ls -lh "image-${{ matrix.arch }}.tar.gz" | |
| - name: Fail on fixable high or critical vulnerabilities | |
| # Scans the saved tarball, so the result describes the bytes publish | |
| # pushes, and the scanner gets no Docker socket. --ignore-unfixed: a | |
| # finding with no fixed package version cannot be cleared by a rebuild. | |
| env: | |
| TRIVY_IMAGE: public.ecr.aws/aquasecurity/trivy:0.76.0@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222 | |
| run: | | |
| set -euo pipefail | |
| docker run --rm \ | |
| -v "$PWD/image-${{ matrix.arch }}.tar.gz:/image.tar.gz:ro" \ | |
| "$TRIVY_IMAGE" image --input /image.tar.gz \ | |
| --scanners vuln --ignore-unfixed --severity HIGH,CRITICAL \ | |
| --db-repository public.ecr.aws/aquasecurity/trivy-db:2 \ | |
| --exit-code 1 --no-progress | |
| - 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-candidate: | |
| needs: [gate, build] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| environment: dockerhub # approval 1: publish + sign (the only job with Docker Hub secrets) | |
| env: | |
| IMAGE_REPO: ${{ needs.gate.outputs.image_repo }} | |
| # Signing rides the publish approval (release owner decision 2026-08-18); | |
| # propagation is the next job, under the ghcr environment's own gate. | |
| outputs: | |
| digest: ${{ steps.push.outputs.digest }} | |
| candidate: ${{ steps.push.outputs.candidate }} | |
| sig-tag: ${{ steps.sign.outputs.sig-tag }} | |
| sig-digest: ${{ steps.sign.outputs.sig-digest }} | |
| permissions: | |
| contents: read | |
| id-token: write # KMS signing role (OIDC) | |
| steps: | |
| - name: Check out (composite signing action lives in-repo) | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - 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 '${{ needs.gate.outputs.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 commit-addressed candidate only | |
| id: push | |
| env: | |
| SHA: ${{ needs.gate.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| REPO='${{ env.IMAGE_REPO }}' | |
| CANDIDATE="sha-${SHA}" | |
| # Idempotent no-overwrite: if the candidate tag exists and holds | |
| # EXACTLY what this run built, fall through to sign and propagate | |
| # (a failed earlier run must be finishable by re-dispatch, or the | |
| # failure-prone tail strands a published-but-unsigned candidate no | |
| # re-dispatch can fix). Identity is compared by per-arch image | |
| # config digest — the image ID, which docker save/load preserves | |
| # and the smoke test certified — with NO registry mutation: a | |
| # different artifact errors before anything is pushed. | |
| if EXISTING=$(docker buildx imagetools inspect "${REPO}:${CANDIDATE}" --format '{{.Manifest.Digest}}' 2>/dev/null); then | |
| RAW=$(skopeo inspect --no-creds --raw "docker://${REPO}@${EXISTING}") | |
| MATCH=true | |
| for arch in amd64 arm64; do | |
| MDIGEST=$(jq -r --arg a "$arch" '.manifests[] | select(.platform.architecture == $a) | .digest' <<< "$RAW") | |
| [[ -n "$MDIGEST" && "$MDIGEST" != "null" ]] || { MATCH=false; break; } | |
| REMOTE_ID=$(skopeo inspect --no-creds --raw "docker://${REPO}@${MDIGEST}" | jq -r '.config.digest') | |
| LOCAL_ID=$(docker image inspect "${REPO}:${CANDIDATE}-${arch}" --format '{{.Id}}') | |
| [[ "$REMOTE_ID" == "$LOCAL_ID" ]] || { MATCH=false; break; } | |
| done | |
| if [[ "$MATCH" != "true" ]]; then | |
| echo "::error::candidate ${REPO}:${CANDIDATE} exists (${EXISTING}) but does not match the images this run built and tested; refusing to overwrite" | |
| exit 1 | |
| fi | |
| echo "::notice::candidate already published with the identical artifact; continuing to sign/propagate" | |
| DIGEST="$EXISTING" | |
| else | |
| # 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}:${CANDIDATE}" \ | |
| --format '{{.Manifest.Digest}}') | |
| fi | |
| echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" | |
| echo "candidate=${CANDIDATE}" >> "$GITHUB_OUTPUT" | |
| - name: Verify both architectures are in the published candidate | |
| run: | | |
| set -euo pipefail | |
| OUT=$(docker buildx imagetools inspect \ | |
| '${{ 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: Verify the candidate anonymously (the TAG, with no credential) | |
| env: | |
| DIGEST: ${{ steps.push.outputs.digest }} | |
| CANDIDATE: ${{ steps.push.outputs.candidate }} | |
| run: | | |
| set -euo pipefail | |
| # --no-creds: skopeo otherwise reads the docker login from | |
| # ~/.docker/config.json, which would make this a maintainer- | |
| # credentialed fetch. And the check must be TAG-addressed: a | |
| # digest-addressed inspect is content-addressed and proves nothing | |
| # about what the tag serves to users. | |
| GOT=$(skopeo inspect --no-creds --format '{{.Digest}}' "docker://${IMAGE_REPO}:${CANDIDATE}") | |
| [[ "$GOT" == "$DIGEST" ]] || { echo "::error::${IMAGE_REPO}:${CANDIDATE} anonymously resolves to ${GOT}, expected ${DIGEST}"; exit 1; } | |
| RAW=$(skopeo inspect --no-creds --raw "docker://${IMAGE_REPO}:${CANDIDATE}") | |
| grep -q '"architecture": *"amd64"' <<< "$RAW" || { echo "::error::amd64 missing from published index"; exit 1; } | |
| grep -q '"architecture": *"arm64"' <<< "$RAW" || { echo "::error::arm64 missing from published index"; exit 1; } | |
| - name: Assume the signing role via OIDC | |
| uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 | |
| with: | |
| role-to-assume: arn:aws:iam::975306274793:role/ExtendDBContainerSigningCI | |
| role-session-name: extenddb-release-sign-${{ github.run_id }} | |
| aws-region: us-east-1 | |
| - name: Sign the candidate (composite action; Rekor ON, tag-based .sig) | |
| id: sign | |
| uses: ./.github/actions/sign-image | |
| with: | |
| image-repo: ${{ needs.gate.outputs.image_repo }} | |
| digest: ${{ steps.push.outputs.digest }} | |
| release-tag: v${{ needs.gate.outputs.version }} | |
| key-arn: arn:aws:kms:us-east-1:975306274793:key/654ab7f4-4a11-45e1-a313-3a6989f3721c | |
| - name: Summarise for the release checklist | |
| if: always() | |
| run: | | |
| { | |
| echo "### Candidate published (NOT promoted)" | |
| echo "" | |
| echo "| field | value |" | |
| echo "|---|---|" | |
| echo "| candidate | \`${{ needs.gate.outputs.image_repo }}:${{ steps.push.outputs.candidate }}\` |" | |
| echo "| index digest | \`${{ steps.push.outputs.digest }}\` |" | |
| echo "| platforms | linux/amd64, linux/arm64 |" | |
| 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 "| signature | \`${{ steps.sign.outputs.sig-tag }}\` (Rekor logged) |" | |
| echo "" | |
| echo "Published and signed on Docker Hub. Next: the propagate job" | |
| echo "(ghcr environment approval) copies image + signature to GHCR" | |
| echo "and ECR; after that, dispatch promote-image with this digest" | |
| echo "to create the version tag and \`latest\`." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # Approval 2 (ghcr environment): propagate image + signature to the mirror | |
| # registries, only after publish + sign completed. GHCR writes are gated by | |
| # the ghcr environment's own reviewers; the ECR role's OIDC trust covers | |
| # both protected environments. All copies go through the copy-artifact | |
| # composite action: idempotent, fail-closed probes, digest-asserted. | |
| propagate: | |
| needs: [gate, publish-candidate] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| environment: ghcr | |
| permissions: | |
| contents: read | |
| packages: write # GHCR | |
| id-token: write # ECR (OIDC role) | |
| env: | |
| IMAGE_REPO: ${{ needs.gate.outputs.image_repo }} | |
| GHCR_REPO: ghcr.io/extenddb/${{ needs.gate.outputs.image }} | |
| ECR_REPO: public.ecr.aws/extenddb/${{ needs.gate.outputs.image }} | |
| DIGEST: ${{ needs.publish-candidate.outputs.digest }} | |
| CANDIDATE: ${{ needs.publish-candidate.outputs.candidate }} | |
| SIG_TAG: ${{ needs.publish-candidate.outputs.sig-tag }} | |
| SIG_DIGEST: ${{ needs.publish-candidate.outputs.sig-digest }} | |
| steps: | |
| - name: Check out (composite action lives in-repo) | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - name: Log in to GHCR | |
| env: | |
| GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Constant username: GHCR ignores it with a GitHub token, and it | |
| # keeps the operator identity out of the run block. | |
| run: | | |
| set -euo pipefail | |
| skopeo login ghcr.io -u x-access-token --password-stdin <<< "$GHCR_TOKEN" | |
| - name: Copy image to GHCR (by digest) | |
| uses: ./.github/actions/copy-artifact | |
| with: | |
| src-repo: ${{ env.IMAGE_REPO }} | |
| dst-repo: ${{ env.GHCR_REPO }} | |
| tag: ${{ env.CANDIDATE }} | |
| digest: ${{ env.DIGEST }} | |
| by-digest: 'true' | |
| - name: Copy signature to GHCR (tag-addressed) | |
| uses: ./.github/actions/copy-artifact | |
| with: | |
| src-repo: ${{ env.IMAGE_REPO }} | |
| dst-repo: ${{ env.GHCR_REPO }} | |
| tag: ${{ env.SIG_TAG }} | |
| digest: ${{ env.SIG_DIGEST }} | |
| by-digest: 'false' | |
| - name: Assume the ECR publisher role via OIDC | |
| uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 | |
| with: | |
| role-to-assume: arn:aws:iam::975306274793:role/ExtendDBEcrPublicGitHubPublisher | |
| role-session-name: extenddb-release-propagate-${{ github.run_id }} | |
| aws-region: us-east-1 | |
| - name: Log in to ECR Public | |
| run: | | |
| set -euo pipefail | |
| aws ecr-public get-login-password --region us-east-1 \ | |
| | skopeo login public.ecr.aws -u AWS --password-stdin | |
| - name: Copy image to ECR Public (by digest) | |
| uses: ./.github/actions/copy-artifact | |
| with: | |
| src-repo: ${{ env.IMAGE_REPO }} | |
| dst-repo: ${{ env.ECR_REPO }} | |
| tag: ${{ env.CANDIDATE }} | |
| digest: ${{ env.DIGEST }} | |
| by-digest: 'true' | |
| - name: Copy signature to ECR Public (tag-addressed) | |
| uses: ./.github/actions/copy-artifact | |
| with: | |
| src-repo: ${{ env.IMAGE_REPO }} | |
| dst-repo: ${{ env.ECR_REPO }} | |
| tag: ${{ env.SIG_TAG }} | |
| digest: ${{ env.SIG_DIGEST }} | |
| by-digest: 'false' | |
| - name: Log out of the mirror registries | |
| if: always() | |
| run: | | |
| skopeo logout ghcr.io || true | |
| skopeo logout public.ecr.aws || true | |
| # Deliberate omissions, and what closing them would cost. | |
| # | |
| # No version tag or `latest`: promotion is promote-image's job — a separate | |
| # human decision (soak time), one approved dispatch, all three registries. | |
| # | |
| # 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. 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. |