Release #665
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 | |
| # Cuts a release when a merge to main bumped the root package version — i.e. | |
| # when the Version PR (maintained by version-pr.yml) has been merged. Triggered | |
| # after CI/CD succeeds so the Docker images for the commit already exist. | |
| # | |
| # A release: tags vX.Y.Z at the version-bump commit, creates the GitHub Release | |
| # from that version's CHANGELOG.md section (flagging schema migrations), verifies | |
| # a seeded previous-release upgrade before publication, retags the CI-built | |
| # images (X.Y.Z, X.Y, latest), and starts the deploy chain | |
| # (staging automatically, production after environment approval). | |
| # | |
| # 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: | |
| 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 }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| # Pin to the exact commit CI/CD built — never main's tip, which may | |
| # already carry a newer version bump (that would skip this release). | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| fetch-depth: 0 | |
| - name: Cut release if this commit bumped the version | |
| id: cut | |
| # GITHUB_TOKEN suffices: the tag/Release it creates is not meant to | |
| # trigger any workflow (deploys are wired via `needs`/dispatch below). | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| VERSION=$(git show "$SHA:package.json" | jq -r .version) | |
| PARENT_VERSION=$(git show "${SHA}^:package.json" | jq -r .version) | |
| TAG="v$VERSION" | |
| # Only the Version PR merge changes the root version. Feature merges add | |
| # changesets, not version bumps, so they no-op here. | |
| if [ "$VERSION" = "$PARENT_VERSION" ]; then | |
| echo "Version unchanged at $VERSION — no release to cut." | |
| echo "released=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| RESUME=false | |
| if existing=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,targetCommitish 2>/dev/null); then | |
| if [ "$(jq -r .isDraft <<< "$existing")" != true ]; then | |
| echo "Release $TAG is already published." | |
| echo "released=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| [ "$(jq -r .targetCommitish <<< "$existing")" = "$SHA" ] || { | |
| echo "::error::Draft $TAG targets a different commit"; exit 1; } | |
| RESUME=true | |
| fi | |
| echo "Cutting $TAG at $SHA (was $PARENT_VERSION)" | |
| PREV_TAG="v$PARENT_VERSION" | |
| previous=$(gh release view "$PREV_TAG" --repo "${{ github.repository }}" --json isDraft,isPrerelease 2>/dev/null) || { | |
| echo "::error::Previous package version $PARENT_VERSION is not a published release"; exit 1; } | |
| jq -e '.isDraft == false and .isPrerelease == false' <<< "$previous" >/dev/null || { | |
| echo "::error::$PREV_TAG is not a stable published release"; exit 1; } | |
| # Release notes = this version's CHANGELOG.md section (the assembled | |
| # changeset entries), with a first-class migration warning when the | |
| # release touches the Liquibase changelog. | |
| NOTES=$(mktemp) | |
| if [ -n "$PREV_TAG" ] && ! git diff --quiet "$PREV_TAG" "$SHA" -- server/application/src/main/resources/db/changelog/; 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 | |
| git show "$SHA:CHANGELOG.md" \ | |
| | awk -v ver="## $VERSION" ' | |
| $0 == ver { on=1; next } | |
| on && /^## / { exit } | |
| on { print } | |
| ' >> "$NOTES" | |
| echo "----- release notes -----"; cat "$NOTES"; echo "-------------------------" | |
| if [ "$RESUME" = false ]; then | |
| gh release create "$TAG" --draft \ | |
| --title "$TAG" \ | |
| --notes-file "$NOTES" \ | |
| --target "$SHA" \ | |
| --repo "${{ github.repository }}" | |
| fi | |
| { | |
| echo "released=true" | |
| echo "version=$VERSION" | |
| echo "major=${VERSION%%.*}" | |
| MINOR="${VERSION#*.}"; echo "minor=${MINOR%%.*}" | |
| echo "tag_name=$TAG" | |
| echo "sha=$SHA" | |
| echo "previous_version=$PARENT_VERSION" | |
| } >> "$GITHUB_OUTPUT" | |
| - 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." >> "$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 upload (release image lock) | |
| 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 | |
| - 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: | |
| SHA: ${{ needs.release.outputs.sha }} | |
| 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 every source digest before publishing any release tag. | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/ls1intum/hephaestus/$img" | |
| for i in $(seq 1 24); do | |
| digest=$(docker buildx imagetools inspect "$FULL_IMAGE:$SHA" --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:$SHA after 120 s" | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| done | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/ls1intum/hephaestus/$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/ls1intum/hephaestus/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" | |
| - 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 | |
| for attempt in 1 2 3; do | |
| trivy image --timeout 90s --download-db-only && break | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "::error::Trivy database download failed after $attempt attempts" | |
| exit 1 | |
| fi | |
| sleep $((attempt * 5)) | |
| done | |
| db_metadata="${TRIVY_CACHE_DIR:-$HOME/.cache/trivy}/db/metadata.json" | |
| jq -e '(.UpdatedAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) as $updated | | |
| ((now - $updated) >= 0) and ((now - $updated) <= 86400)' \ | |
| "$db_metadata" >/dev/null | |
| cp "$db_metadata" evidence/trivy-db.json | |
| 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 | |
| syft "$platform_ref" --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/ls1intum/hephaestus/$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 | |
| - 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/ls1intum/hephaestus@${{ 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" | |
| - 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 | |
| gh release upload "$TAG_NAME" \ | |
| "$ASSET" "${ASSET}.sigstore.json" evidence/* \ | |
| --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: | |
| previous-application-image: ghcr.io/ls1intum/hephaestus/application-server:${{ needs.release.outputs.previous_version }} | |
| candidate-application-image: ghcr.io/ls1intum/hephaestus/application-server@${{ needs.tag-images.outputs.application-server-digest }} | |
| postgres-image: ghcr.io/ls1intum/hephaestus/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 --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 }} | |
| # 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] | |
| 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] |