Release #704
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
| name: Release | |
| # Versioning contract: docs/admin/compatibility-policy.mdx | |
| on: | |
| workflow_run: # zizmor: ignore[dangerous-triggers] The guard authenticates the completed run before privileged jobs start. | |
| workflows: ["CI/CD"] | |
| types: [completed] | |
| branches: [main] | |
| # Serialize promotion of the moving series and latest tags. | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| permissions: {} | |
| jobs: | |
| release: | |
| # Only real pushes to our own main — a fork PR's CI/CD run reports | |
| # head_branch of the fork; without these guards a fork branch named "main" | |
| # could drive the privileged release job. | |
| if: >- | |
| ${{ github.event.workflow_run.conclusion == 'success' | |
| && github.event.workflow_run.event == 'push' | |
| && github.event.workflow_run.head_repository.full_name == github.repository }} | |
| timeout-minutes: 15 | |
| runs-on: ubuntu-latest | |
| permissions: | |
| # No release is created here — tag-images creates the draft once the evidence gate has | |
| # passed. Draft releases are invisible to tokens without push access, and the plan turns on | |
| # whether this version's release is a draft, so listing them still needs write. | |
| contents: write | |
| outputs: | |
| released: ${{ steps.cut.outputs.released }} | |
| version: ${{ steps.cut.outputs.version }} | |
| major: ${{ steps.cut.outputs.major }} | |
| minor: ${{ steps.cut.outputs.minor }} | |
| tag_name: ${{ steps.cut.outputs.tag_name }} | |
| previous_version: ${{ steps.cut.outputs.previous_version }} | |
| sha: ${{ steps.cut.outputs.sha }} | |
| migrations: ${{ steps.cut.outputs.migrations }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| install: "none" | |
| # Decides whether this commit cuts a release, and creates nothing. The rule — a version with | |
| # no published release is cut, from whichever commit carries it — and the four cases it | |
| # decides between are documented in scripts/plan-release.ts. The draft is created in | |
| # tag-images, after the evidence gate, so a gate failure leaves nothing behind: release images | |
| # are promoted by digest and never rebuilt, so a draft cut at a commit the gate rejected can | |
| # never pass. The same version then re-cuts from the commit that carries the fix. | |
| - name: Plan the release for this commit | |
| id: cut | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| # The commit CI/CD built has to be one of ours; everything downstream checks it out. | |
| git merge-base --is-ancestor "$SHA" origin/main || { | |
| echo "::error::Commit $SHA is not on main"; exit 1; } | |
| node scripts/plan-release.ts "$SHA" | |
| - name: Summary | |
| if: steps.cut.outputs.released == 'true' | |
| run: | | |
| echo "## Preparing ${{ steps.cut.outputs.tag_name }}" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Evidence verification is in progress; the draft is created once it passes." >> "$GITHUB_STEP_SUMMARY" | |
| tag-images: | |
| needs: release | |
| if: needs.release.outputs.released == 'true' | |
| timeout-minutes: 30 | |
| runs-on: ubuntu-latest | |
| permissions: | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| contents: write # gh release create/upload, once the evidence gate has passed | |
| outputs: | |
| agent-pi-digest: ${{ steps.retag.outputs.agent-pi-digest }} | |
| application-server-digest: ${{ steps.retag.outputs.application-server-digest }} | |
| postgres-digest: ${{ steps.retag.outputs.postgres-digest }} | |
| steps: | |
| - name: Check out the released tree | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.release.outputs.sha }} | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - name: Log in to GitHub Container Registry | |
| uses: ./.github/actions/ghcr-login | |
| with: | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Capture release image digests | |
| id: retag | |
| env: | |
| SOURCE_TAG: run-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} | |
| run: | | |
| set -euo pipefail | |
| mapfile -t IMAGES < <(jq -er '.images[]' security/release-images.json) | |
| [ "${#IMAGES[@]}" -gt 0 ] || { echo "::error::Release image inventory is empty"; exit 1; } | |
| declare -A DIGESTS=() | |
| : > release-images.tsv | |
| # Resolve the complete image inventory before generating evidence. | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/hephaestus-build/$img" | |
| for i in $(seq 1 24); do | |
| digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SOURCE_TAG" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest' 2>/dev/null) || true | |
| if [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then | |
| DIGESTS[$img]=$digest | |
| break | |
| fi | |
| if [ "$i" -ge 24 ]; then | |
| echo "::error::Could not resolve a valid digest for $FULL_IMAGE:$SOURCE_TAG after 120 s" | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| done | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/hephaestus-build/$img" | |
| echo "::group::$img" | |
| SRC_DIGEST=${DIGESTS[$img]} | |
| echo "$img source digest: $SRC_DIGEST" | |
| printf '%s\t%s\n' "$img" "$SRC_DIGEST" >> release-images.tsv | |
| case "$img" in | |
| agent-pi) echo "agent-pi-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;; | |
| application-server) echo "application-server-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;; | |
| postgres) echo "postgres-digest=$SRC_DIGEST" >> "$GITHUB_OUTPUT" ;; | |
| esac | |
| echo "::endgroup::" | |
| done | |
| - name: Verify Node-only agent-pi sandbox | |
| env: | |
| IMAGE: ghcr.io/hephaestus-build/agent-pi@${{ steps.retag.outputs.agent-pi-digest }} | |
| LAYOUT: server/application/src/main/java/de/tum/cit/aet/hephaestus/agent/runtime/SandboxLayout.java | |
| run: | | |
| set -euo pipefail | |
| docker pull "$IMAGE" | |
| docker run --rm --entrypoint /bin/sh "$IMAGE" -c \ | |
| 'node --version && for runtime in npm npx corepack yarn yarnpkg pnpm pnpx; do ! command -v "$runtime" || exit 1; done' | |
| expected=$(grep -oE 'RUNTIME_CONTRACT_VERSION = [0-9]+' "$LAYOUT" | grep -oE '[0-9]+$') | |
| [ -n "$expected" ] || { echo "::error::Could not read RUNTIME_CONTRACT_VERSION from $LAYOUT"; exit 1; } | |
| declared=$(docker inspect --format '{{index .Config.Labels "hephaestus.agent.runtime-contract"}}' "$IMAGE") | |
| if [ "$declared" != "$expected" ]; then | |
| echo "::error::agent-pi implements runtime contract '${declared:-<none>}' but the released server stages for v${expected} — refusing to publish a pin for an unmatched pair." | |
| exit 1 | |
| fi | |
| echo "agent-pi implements runtime contract v${expected}" | |
| - uses: ./.github/actions/setup-release-security-tools | |
| - uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| install: "none" | |
| # The database the whole evidence bundle is scanned against: recorded into the bundle, and | |
| # refused if it is over a day old, so a stale mirror cannot be signed as a fresh scan. | |
| - uses: ./.github/actions/download-trivy-db | |
| with: | |
| max-age-hours: "24" | |
| metadata-path: evidence/trivy-db.json | |
| - name: Generate and enforce release evidence | |
| env: | |
| TRIVY_USERNAME: ${{ github.actor }} | |
| TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p evidence | |
| : > release-platforms.tsv | |
| started=$SECONDS | |
| jq -n --arg syft "$(syft version -o json | jq -r .version)" \ | |
| --arg trivy "$(trivy version --format json | jq -r .Version)" \ | |
| --arg cosign "$(cosign version --json | jq -r .gitVersion)" \ | |
| '{syft: $syft, trivy: $trivy, cosign: $cosign}' > evidence/tool-versions.json | |
| generate_evidence() { | |
| local image=$1 repository=$2 digest=$3 provenance=$4 | |
| local ref="$repository@$digest" | |
| for platform in linux/amd64 linux/arm64; do | |
| suffix=${platform//\//-} | |
| architecture=${platform#*/} | |
| platform_digest=$(docker buildx imagetools inspect "$ref" --raw | jq -er \ | |
| --arg architecture "$architecture" \ | |
| '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture) | .digest') | |
| [[ "$platform_digest" =~ ^sha256:[a-f0-9]{64}$ ]] || { echo "::error::$image $platform digest is malformed"; exit 1; } | |
| platform_ref="$repository@$platform_digest" | |
| printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$image" "$platform" "$digest" "$platform_digest" "$repository" "$provenance" >> release-platforms.tsv | |
| # Scan the registry artifact, never a daemon copy of it: a daemon pull | |
| # re-serializes an OCI manifest as Docker schema 2, so the SBOM would | |
| # record a locally computed manifestDigest instead of the released one. | |
| # --platform makes Syft fail loudly if the digest is not this platform's. | |
| syft --from registry "$platform_ref" --platform "$platform" --scope squashed \ | |
| -o "syft-json=evidence/$image-$suffix.syft.json" \ | |
| -o "spdx-json=evidence/$image-$suffix.spdx.json" \ | |
| -o "cyclonedx-json=evidence/$image-$suffix.cdx.json" | |
| trivy image --skip-db-update --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref" | |
| trivy image --skip-db-update --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref" | |
| done | |
| } | |
| while IFS=$'\t' read -r image digest; do | |
| generate_evidence "$image" "ghcr.io/hephaestus-build/$image" "$digest" first-party | |
| done < release-images.tsv | |
| jq -r '.upstream[] | [.name, .repository, .digest] | @tsv' security/release-images.json | | |
| while IFS=$'\t' read -r image repository digest; do | |
| generate_evidence "$image" "$repository" "$digest" upstream | |
| done | |
| duration=$((SECONDS - started)) | |
| jq -n \ | |
| --arg schemaVersion "1" \ | |
| --arg generatedAt "$(date -u +%FT%TZ)" \ | |
| --arg release "${{ needs.release.outputs.tag_name }}" \ | |
| --arg commit "${{ needs.release.outputs.sha }}" \ | |
| --argjson durationSeconds "$duration" \ | |
| --rawfile subjects release-platforms.tsv \ | |
| '{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit, | |
| generatedAt: $generatedAt, durationSeconds: $durationSeconds, | |
| subjects: ($subjects | split("\n") | map(select(length > 0) | split("\t") | | |
| {image: .[0], platform: .[1], indexDigest: .[2], digest: .[3], repository: .[4], provenance: .[5]}))}' \ | |
| > evidence/manifest.json | |
| cp security/vulnerability-policy.json evidence/vulnerability-policy.json | |
| cp security/release-images.json evidence/release-images.json | |
| node scripts/verify-release-evidence.ts evidence --write-validation | |
| jq -er '.subjects[] | select(.provenance == "first-party") | [.repository, .digest, .image, .platform] | @tsv' evidence/manifest.json | | |
| while IFS=$'\t' read -r repository digest image platform; do | |
| suffix=${platform//\//-} | |
| cosign attest --yes --type spdxjson --predicate "evidence/$image-$suffix.spdx.json" "$repository@$digest" | |
| done | |
| (cd evidence && sha256sum -- * > SHA256SUMS) | |
| - name: Verify evidence from registry subjects | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/verify-release-evidence.ts evidence --verify-signatures | |
| - name: Write release image lock | |
| id: pin | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| ASSET="release-v${VERSION}.json" | |
| jq '{schemaVersion, release, commit, | |
| images: [.subjects[]] | |
| | group_by(.image) | |
| | map({image: .[0].image, repository: .[0].repository, provenance: .[0].provenance, | |
| indexDigest: .[0].indexDigest, | |
| platforms: (map({key: .platform, value: .digest}) | from_entries)})}' \ | |
| evidence/manifest.json > "$ASSET" | |
| node scripts/release-image-lock.ts "$ASSET" evidence/manifest.json \ | |
| "v${VERSION}" /tmp/release-lock.env | |
| echo "asset-path=$ASSET" >> "$GITHUB_OUTPUT" | |
| jq . "$ASSET" | |
| - name: Generate subject checksums | |
| id: subjects | |
| env: | |
| ASSET: ${{ steps.pin.outputs.asset-path }} | |
| run: | | |
| set -euo pipefail | |
| while IFS=$'\t' read -r image digest; do | |
| printf '%s %s\n' "${digest#sha256:}" "$image" | |
| done < release-images.tsv > subjects.sha256 | |
| sha256sum "$ASSET" >> subjects.sha256 | |
| cat subjects.sha256 | |
| # purl namespace/name are canonically lowercase; derive them from the run | |
| # context so the attestation follows a repository transfer (issue #1599). | |
| echo "purl-repository=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" | |
| - name: Attest release image lock | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-checksums: subjects.sha256 | |
| predicate-type: https://in-toto.io/attestation/release/v0.1 | |
| predicate: | | |
| { "purl": "pkg:github/${{ steps.subjects.outputs.purl-repository }}@${{ needs.release.outputs.tag_name }}" } | |
| - name: Sign release image lock | |
| env: | |
| ASSET: ${{ steps.pin.outputs.asset-path }} | |
| run: | | |
| set -euo pipefail | |
| cosign sign-blob --yes --bundle "${ASSET}.sigstore.json" "$ASSET" | |
| - name: Verify signature with the deploy-side identity (fail fast) | |
| env: | |
| ASSET: ${{ steps.pin.outputs.asset-path }} | |
| run: | | |
| set -euo pipefail | |
| cosign verify-blob \ | |
| --bundle "${ASSET}.sigstore.json" \ | |
| --certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| "$ASSET" | |
| # First and only write to the release surface: every step above fails without creating one, | |
| # so a rejected release leaves no draft to delete and no tag — a draft materialises none — | |
| # and the version re-cuts unchanged from a commit that carries the fix. | |
| - name: Create the draft release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| VERSION: ${{ needs.release.outputs.version }} | |
| SHA: ${{ needs.release.outputs.sha }} | |
| MIGRATIONS: ${{ needs.release.outputs.migrations }} | |
| run: | | |
| set -euo pipefail | |
| # The release job has already proved that any existing release is a draft at this commit. | |
| if gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| echo "Resuming the existing draft $TAG_NAME." | |
| exit 0 | |
| fi | |
| NOTES=$(mktemp) | |
| if [ "$MIGRATIONS" = true ]; then | |
| { | |
| echo "> [!WARNING]" | |
| echo "> This release contains **schema migrations**. They run automatically on startup — back up your database before upgrading. See the [migration guide](${{ github.server_url }}/${{ github.repository }}/blob/main/MIGRATION.md)." | |
| echo "" | |
| } >> "$NOTES" | |
| fi | |
| # CHANGELOG.md as checked out at the released commit; awk exits at the next section. | |
| awk -v ver="## $VERSION" ' | |
| $0 == ver { on=1; next } | |
| on && /^## / { exit } | |
| on { print } | |
| ' CHANGELOG.md >> "$NOTES" | |
| echo "----- release notes -----"; cat "$NOTES"; echo "-------------------------" | |
| gh release create "$TAG_NAME" --draft \ | |
| --title "$TAG_NAME" \ | |
| --notes-file "$NOTES" \ | |
| --target "$SHA" \ | |
| --repo "${{ github.repository }}" | |
| - name: Upload release image lock to GitHub Release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| ASSET: ${{ steps.pin.outputs.asset-path }} | |
| run: | | |
| set -euo pipefail | |
| # --clobber so a re-run over a resumed draft replaces its assets instead of failing on | |
| # every name that already exists. | |
| gh release upload "$TAG_NAME" \ | |
| "$ASSET" "${ASSET}.sigstore.json" evidence/* \ | |
| --clobber \ | |
| --repo "${{ github.repository }}" | |
| upgrade-test: | |
| needs: [release, tag-images] | |
| if: needs.release.outputs.released == 'true' | |
| uses: ./.github/workflows/release-upgrade.yml | |
| permissions: | |
| contents: read | |
| packages: read | |
| with: | |
| # The previous release keeps the namespace it was published under (GHCR packages | |
| # do not transfer between organizations — issue #1599), so the reusable workflow | |
| # resolves its repository per version from security/release-identities.json. | |
| previous-version: ${{ needs.release.outputs.previous_version }} | |
| candidate-application-image: ghcr.io/hephaestus-build/application-server@${{ needs.tag-images.outputs.application-server-digest }} | |
| postgres-image: ghcr.io/hephaestus-build/postgres@${{ needs.tag-images.outputs.postgres-digest }} | |
| candidate-source-sha: ${{ needs.release.outputs.sha }} | |
| supported-host-smoke: | |
| needs: [release, tag-images] | |
| if: needs.release.outputs.released == 'true' | |
| name: Host smoke (${{ matrix.architecture }}) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - architecture: amd64 | |
| runner: ubuntu-24.04 | |
| - architecture: arm64 | |
| runner: ubuntu-24.04-arm | |
| runs-on: ${{ matrix.runner }} | |
| timeout-minutes: 20 | |
| permissions: | |
| # The release is still a draft here, and draft releases are visible only to tokens with push | |
| # access, so `gh release download` in prepare-release-lock.ts needs contents: write. | |
| contents: write | |
| packages: read | |
| steps: | |
| - name: Check out the released tree | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.release.outputs.sha }} | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| install: "none" | |
| - name: Install Cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Log in to GitHub Container Registry | |
| uses: ./.github/actions/ghcr-login | |
| with: | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Smoke-test the blessed install | |
| env: | |
| ARCHITECTURE: ${{ matrix.architecture }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| RELEASE_SHA: ${{ needs.release.outputs.sha }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| set -euo pipefail | |
| cd docker/self-host | |
| cleanup() { | |
| status=$? | |
| if [ "$status" -ne 0 ]; then | |
| docker compose --env-file .env --env-file release-lock.env logs --no-color || true | |
| fi | |
| docker compose --env-file .env --env-file release-lock.env down -v --remove-orphans || true | |
| exit "$status" | |
| } | |
| trap cleanup EXIT | |
| actual_arch=$(docker version --format '{{.Server.Arch}}') | |
| [ "$actual_arch" = "$ARCHITECTURE" ] || { echo "::error::Expected native $ARCHITECTURE, got $actual_arch"; exit 1; } | |
| . /etc/os-release | |
| [ "$ID" = ubuntu ] && [ "$VERSION_ID" = 24.04 ] || { echo "::error::Ubuntu 24.04 is required"; exit 1; } | |
| docker_major=$(docker version --format '{{.Server.Version}}' | cut -d. -f1) | |
| [ "$docker_major" -ge 28 ] || { echo "::error::Docker Engine 28 or newer is required"; exit 1; } | |
| compose_version=$(docker compose version --short) | |
| [ "$(printf '%s\n' 2.24.4 "$compose_version" | sort -V | head -n1)" = 2.24.4 ] || { | |
| echo "::error::Docker Compose 2.24.4 or newer is required"; exit 1; } | |
| ./setup.sh | |
| sed -i \ | |
| -e 's/^APP_HOSTNAME=$/APP_HOSTNAME=hephaestus-smoke.invalid/' \ | |
| -e 's/^ACME_EMAIL=$/ACME_EMAIL=release-smoke@example.invalid/' \ | |
| -e 's/^GH_OAUTH_CLIENT_ID=$/GH_OAUTH_CLIENT_ID=release-smoke/' \ | |
| -e 's/^GH_OAUTH_CLIENT_SECRET=$/GH_OAUTH_CLIENT_SECRET=release-smoke-secret/' \ | |
| -e 's/^HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=$/HEPHAESTUS_AUTH_BOOTSTRAP_ADMINS=github:1/' \ | |
| .env | |
| node ../../scripts/prepare-release-lock.ts "$TAG_NAME" | |
| docker compose --env-file .env --env-file release-lock.env config --quiet | |
| docker compose --env-file .env --env-file release-lock.env up -d --wait --wait-timeout 600 | |
| curl --fail --insecure --silent --show-error --max-time 10 --noproxy '*' \ | |
| --resolve hephaestus-smoke.invalid:443:127.0.0.1 \ | |
| https://hephaestus-smoke.invalid/ >/dev/null | |
| jq -n \ | |
| --arg schemaVersion "1" \ | |
| --arg release "$TAG_NAME" \ | |
| --arg commit "$RELEASE_SHA" \ | |
| --arg testedAt "$(date -u +%FT%TZ)" \ | |
| --arg os "$(. /etc/os-release; printf '%s %s' "$NAME" "$VERSION_ID")" \ | |
| --arg architecture "$actual_arch" \ | |
| --arg dockerEngine "$(docker version --format '{{.Server.Version}}')" \ | |
| --arg dockerCompose "$compose_version" \ | |
| --argjson services "$(docker compose --env-file .env --env-file release-lock.env ps --all --format json | jq -s 'map({service: .Service, state: .State, health: .Health, exitCode: .ExitCode}) | sort_by(.service)')" \ | |
| '{schemaVersion: ($schemaVersion | tonumber), release: $release, commit: $commit, | |
| testedAt: $testedAt, host: {os: $os, architecture: $architecture, | |
| dockerEngine: $dockerEngine, dockerCompose: $dockerCompose}, services: $services}' \ | |
| > "../../host-smoke-$ARCHITECTURE.json" | |
| - name: Upload smoke-test record | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: host-smoke-${{ matrix.architecture }} | |
| path: host-smoke-${{ matrix.architecture }}.json | |
| if-no-files-found: error | |
| publish-release: | |
| needs: [release, tag-images, upgrade-test, supported-host-smoke] | |
| if: needs.release.outputs.released == 'true' | |
| timeout-minutes: 20 | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| packages: write | |
| attestations: read | |
| steps: | |
| - name: Checkout release verifier | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.release.outputs.sha }} | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - uses: ./.github/actions/setup-node-pnpm | |
| with: | |
| install: "none" | |
| - name: Install Cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Log in to GitHub Container Registry | |
| uses: ./.github/actions/ghcr-login | |
| with: | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download supported-host smoke records | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: host-smoke-* | |
| path: host-smoke | |
| merge-multiple: true | |
| - name: Verify supported-host smoke records | |
| env: | |
| RELEASE_SHA: ${{ needs.release.outputs.sha }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| set -euo pipefail | |
| jq -s -e --arg release "$TAG_NAME" --arg commit "$RELEASE_SHA" ' | |
| length == 2 and | |
| ([.[].host.architecture] | sort) == ["amd64", "arm64"] and | |
| all(.[]; | |
| .schemaVersion == 1 and .release == $release and .commit == $commit and | |
| (.testedAt | fromdateiso8601) and | |
| .host.os == "Ubuntu 24.04" and | |
| (.host.dockerEngine | type) == "string" and | |
| (.host.dockerCompose | type) == "string" and | |
| (.services | length) > 0 and | |
| ([.services[].service] | length) == ([.services[].service] | unique | length) and | |
| all(.services[]; | |
| if .state == "exited" then .exitCode == 0 | |
| else .state == "running" and .health == "healthy" end))' host-smoke/*.json >/dev/null | |
| - name: Verify durable assets from a clean environment | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| set -euo pipefail | |
| mkdir evidence | |
| gh release download "$TAG_NAME" --repo "${{ github.repository }}" --dir evidence | |
| (cd evidence && sha256sum -c SHA256SUMS) | |
| lock="release-${TAG_NAME}.json" | |
| cosign verify-blob \ | |
| --bundle "evidence/$lock.sigstore.json" \ | |
| --certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| "evidence/$lock" | |
| node scripts/release-image-lock.ts "evidence/$lock" evidence/manifest.json \ | |
| "$TAG_NAME" /tmp/release-lock.env | |
| node scripts/verify-release-evidence.ts evidence --verify-signatures | |
| asset="evidence/release-${TAG_NAME}.json" | |
| cosign verify-blob --bundle "${asset}.sigstore.json" \ | |
| --certificate-identity '${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' "$asset" >/dev/null | |
| - name: Promote verified image digests | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| SERIES: ${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }} | |
| run: | | |
| set -euo pipefail | |
| jq -er '[.subjects[] | select(.provenance == "first-party") | [.repository, .indexDigest]] | unique[] | @tsv' evidence/manifest.json | | |
| while IFS=$'\t' read -r ref digest; do | |
| docker buildx imagetools create -t "$ref:$VERSION" "$ref@$digest" | |
| promoted=$(docker buildx imagetools inspect "$ref:$VERSION" --format '{{json .Manifest}}' | jq -r .digest) | |
| [ "$promoted" = "$digest" ] || { echo "::error::Promotion changed $ref:$VERSION digest"; exit 1; } | |
| done | |
| jq -er '[.subjects[] | select(.provenance == "first-party") | [.repository, .indexDigest]] | unique[] | @tsv' evidence/manifest.json | | |
| while IFS=$'\t' read -r ref digest; do | |
| docker buildx imagetools create -t "$ref:$SERIES" -t "$ref:latest" "$ref@$digest" | |
| for tag in "$SERIES" latest; do | |
| promoted=$(docker buildx imagetools inspect "$ref:$tag" --format '{{json .Manifest}}' | jq -r .digest) | |
| [ "$promoted" = "$digest" ] || { echo "::error::Promotion changed $ref:$tag digest"; exit 1; } | |
| done | |
| done | |
| - name: Publish release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG_NAME: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| set -euo pipefail | |
| gh release upload "$TAG_NAME" host-smoke/*.json --clobber --repo "${{ github.repository }}" | |
| gh release edit "$TAG_NAME" --repo "${{ github.repository }}" --draft=false | |
| [ "$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = true ] || { | |
| echo "::error::Repository immutable releases must be enabled before publishing"; exit 1; } | |
| deploy-staging: | |
| needs: [release, tag-images, publish-release] | |
| if: needs.release.outputs.released == 'true' | |
| uses: ./.github/workflows/deploy-staging.yml | |
| permissions: | |
| contents: read | |
| packages: read | |
| with: | |
| image-tag: ${{ needs.release.outputs.tag_name }} | |
| secrets: inherit # zizmor: ignore[secrets-inherit] | |
| deploy-production: | |
| needs: [release, deploy-staging] | |
| if: needs.release.outputs.released == 'true' | |
| uses: ./.github/workflows/deploy-prod.yml | |
| permissions: | |
| contents: read | |
| with: | |
| image-tag: ${{ needs.release.outputs.tag_name }} | |
| # Deploy credentials include organization secrets, which reach nested reusable | |
| # workflows only through inheritance — environment secrets alone do not cover them. | |
| secrets: inherit # zizmor: ignore[secrets-inherit] |