Skip to content

release-image

release-image #18

Workflow file for this run

# 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.