Release #612
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), 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: | |
| 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 }} | |
| 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 }} | |
| 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=$(git tag --list 'v*' --sort=-v:refname | head -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](https://github.com/ls1intum/Hephaestus/blob/main/MIGRATION.md)." | |
| echo "" | |
| } >> "$NOTES" | |
| fi | |
| # Slice the `## <version>` section: stop at the next release header or | |
| # the footer's `---` fence. Exact-string header compare is regex-safe | |
| # against the dots in the version. (A literal `---` inside a changeset | |
| # body would truncate notes — our entries are short prose, so this is | |
| # accepted over more fragile parsing.) | |
| 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" | |
| } >> "$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' | |
| 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 }} | |
| steps: | |
| - name: Check out the released tree | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ needs.release.outputs.sha }} | |
| fetch-depth: 1 | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Capture release image digests | |
| id: retag | |
| env: | |
| SHA: ${{ needs.release.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| # Authoritative registry-side digest of the manifest list. | |
| manifest_digest() { | |
| docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' | jq -r '.digest' | |
| } | |
| mapfile -t IMAGES < <(jq -er '.images[]' security/release-images.json) | |
| [ "${#IMAGES[@]}" -gt 0 ] || { echo "::error::Release image inventory is empty"; exit 1; } | |
| : > release-images.tsv | |
| # Probe all images first; refuse to partial-publish if any is missing. | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/ls1intum/hephaestus/$img" | |
| for i in $(seq 1 24); do | |
| if docker buildx imagetools inspect "$FULL_IMAGE:$SHA" > /dev/null 2>&1; then break; fi | |
| if [ "$i" -ge 24 ]; then | |
| echo "::error::Image $FULL_IMAGE:$SHA not found 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=$(manifest_digest "$FULL_IMAGE:$SHA") | |
| [[ "$SRC_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] || { | |
| echo "::error::$img source digest is malformed: ${SRC_DIGEST:-<empty>}"; exit 1; } | |
| 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" ;; | |
| esac | |
| echo "::endgroup::" | |
| done | |
| # The pin asset below tells every production deploy to run THIS agent image, so this is the | |
| # last point at which an image the released server cannot drive is still catchable. The | |
| # sandbox is Bun-only (ADR 0030), so `node` must be absent, not present. | |
| - name: Smoke-test agent-pi | |
| 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 \ | |
| 'bun --version && ! command -v node >/dev/null 2>&1' | |
| 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-bun | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - 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 | |
| trivy image --download-db-only | |
| 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" | |
| bun scripts/check-release-sbom.ts \ | |
| "evidence/$image-$suffix.syft.json" \ | |
| "evidence/$image-$suffix.spdx.json" \ | |
| "evidence/$image-$suffix.cdx.json" \ | |
| "$platform_digest" "$platform" "evidence/$image-$suffix.sbom-summary.json" | |
| trivy image --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref" | |
| bun scripts/check-release-vulnerabilities.ts "$image" "evidence/$image-$suffix.trivy.json" security/vulnerability-policy.json "evidence/$image-$suffix.policy.json" | |
| if [ "$provenance" = first-party ]; then | |
| cosign attest --yes --type spdxjson --predicate "evidence/$image-$suffix.spdx.json" "$platform_ref" | |
| fi | |
| 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 | |
| (cd evidence && sha256sum -- * > SHA256SUMS) | |
| - name: Verify evidence from registry subjects | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| while IFS=$'\t' read -r image platform _ digest repository provenance; do | |
| [ "$provenance" = first-party ] || continue | |
| ref="$repository@$digest" | |
| suffix=${platform//\//-} | |
| cosign verify-attestation \ | |
| --type spdxjson \ | |
| --certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| "$ref" > verified-attestations.json | |
| jq -e --slurpfile sbom "evidence/$image-$suffix.spdx.json" \ | |
| 'any(.[]; (.payload | @base64d | fromjson | .predicate) == $sbom[0])' \ | |
| verified-attestations.json >/dev/null | |
| done < release-platforms.tsv | |
| while IFS=$'\t' read -r image digest; do | |
| ref="ghcr.io/ls1intum/hephaestus/$image@$digest" | |
| cosign verify "$ref" \ | |
| --certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/reusable-docker-build.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| --certificate-github-workflow-repository '${{ github.repository }}' >/dev/null | |
| gh attestation verify "oci://$ref" --owner "${{ github.repository_owner }}" \ | |
| --signer-workflow '${{ github.repository }}/.github/workflows/reusable-docker-build.yml' >/dev/null | |
| done < release-images.tsv | |
| - 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" | |
| bun 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@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 | |
| 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 'https://github.com/ls1intum/Hephaestus/.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 }}" | |
| publish-release: | |
| needs: [release, tag-images] | |
| if: needs.release.outputs.released == 'true' | |
| 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-bun | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - 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) | |
| for file in manifest.json release-images.json tool-versions.json trivy-db.json vulnerability-policy.json; do | |
| test -s "evidence/$file" | |
| done | |
| lock="release-${TAG_NAME}.json" | |
| cosign verify-blob \ | |
| --bundle "evidence/$lock.sigstore.json" \ | |
| --certificate-identity 'https://github.com/ls1intum/Hephaestus/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| "evidence/$lock" | |
| bun scripts/release-image-lock.ts "evidence/$lock" evidence/manifest.json \ | |
| "$TAG_NAME" /tmp/release-lock.env | |
| jq -e --slurpfile inventory evidence/release-images.json \ | |
| '.schemaVersion == 1 and | |
| ([.subjects[].image] | unique == (($inventory[0].images + [$inventory[0].upstream[].name]) | sort)) and | |
| (group_by(.image) | all(.[]; [.[].platform] == ["linux/amd64", "linux/arm64"])) and | |
| all(.subjects[]; (.digest | test("^sha256:[a-f0-9]{64}$")) and (.indexDigest | test("^sha256:[a-f0-9]{64}$")))' \ | |
| evidence/manifest.json >/dev/null | |
| jq -er '.subjects[] | [.image, .platform, .digest, .repository, .provenance] | @tsv' evidence/manifest.json | | |
| while IFS=$'\t' read -r image platform digest repository provenance; do | |
| suffix=${platform//\//-} | |
| bun scripts/check-release-sbom.ts \ | |
| "evidence/$image-$suffix.syft.json" \ | |
| "evidence/$image-$suffix.spdx.json" \ | |
| "evidence/$image-$suffix.cdx.json" \ | |
| "$digest" "$platform" /tmp/sbom-summary.json | |
| cmp "evidence/$image-$suffix.sbom-summary.json" /tmp/sbom-summary.json | |
| for kind in trivy policy; do | |
| test -s "evidence/$image-$suffix.$kind.json" | |
| done | |
| if [ "$provenance" = first-party ]; then | |
| cosign verify-attestation --type spdxjson \ | |
| --certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| "$repository@$digest" > verified-attestations.json | |
| jq -e --slurpfile sbom "evidence/$image-$suffix.spdx.json" \ | |
| 'any(.[]; (.payload | @base64d | fromjson | .predicate) == $sbom[0])' \ | |
| verified-attestations.json >/dev/null | |
| fi | |
| index_digest=$(jq -er --arg image "$image" --arg platform "$platform" \ | |
| '.subjects[] | select(.image == $image and .platform == $platform) | .indexDigest' evidence/manifest.json) | |
| architecture=${platform#*/} | |
| docker buildx imagetools inspect "$repository@$index_digest" --raw | | |
| jq -e --arg architecture "$architecture" --arg digest "$digest" \ | |
| 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture and .digest == $digest)' >/dev/null | |
| done | |
| jq -er '[.subjects[] | select(.provenance == "first-party") | [.repository, .indexDigest]] | unique[] | @tsv' evidence/manifest.json | | |
| while IFS=$'\t' read -r repository digest; do | |
| ref="$repository@$digest" | |
| cosign verify "$ref" \ | |
| --certificate-identity 'https://github.com/${{ github.repository }}/.github/workflows/reusable-docker-build.yml@refs/heads/main' \ | |
| --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ | |
| --certificate-github-workflow-repository '${{ github.repository }}' >/dev/null | |
| gh attestation verify "oci://$ref" --owner "${{ github.repository_owner }}" \ | |
| --signer-workflow '${{ github.repository }}/.github/workflows/reusable-docker-build.yml' >/dev/null | |
| done | |
| asset="evidence/release-${TAG_NAME}.json" | |
| cosign verify-blob --bundle "${asset}.sigstore.json" \ | |
| --certificate-identity 'https://github.com/ls1intum/Hephaestus/.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 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 | |
| deploy-production: | |
| needs: [release, deploy-staging] | |
| if: needs.release.outputs.released == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Trigger Production Deploy | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| github-token: ${{ secrets.GH_PAT }} | |
| script: | | |
| await github.rest.actions.createWorkflowDispatch({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| workflow_id: 'deploy-prod.yml', | |
| ref: 'main', | |
| inputs: { | |
| 'image-tag': '${{ needs.release.outputs.tag_name }}' | |
| } | |
| }); | |
| console.log('✅ Production deployment triggered for ${{ needs.release.outputs.tag_name }}'); | |
| - name: Summary | |
| run: | | |
| echo "## 🚀 Production Deployment Triggered" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Version **${{ needs.release.outputs.tag_name }}** deployment to production has been triggered." >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "⏳ Waiting for approval in the [Deploy to Production](https://github.com/${{ github.repository }}/actions/workflows/deploy-prod.yml) workflow." >> $GITHUB_STEP_SUMMARY |