Release #633
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: | |
| 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](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" | |
| 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: 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" ;; | |
| postgres) echo "postgres-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 Node-only (ADR 0036), so `bun` and every package-manager CLI must be absent. | |
| - 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 \ | |
| 'node --version && for runtime in bun 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-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" | |
| node 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-validation.json" | |
| trivy image --scanners vuln --format json --output "evidence/$image-$suffix.trivy.json" "$platform_ref" | |
| trivy image --scanners license --format json --output "evidence/$image-$suffix.license.json" "$platform_ref" | |
| jq -e --arg ref "$platform_ref" \ | |
| '.ArtifactName == $ref and (.Results | type == "array")' \ | |
| "evidence/$image-$suffix.license.json" >/dev/null | |
| node scripts/check-release-vulnerabilities.ts "$image" "$platform" "$platform_digest" "$repository" \ | |
| "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" | |
| 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@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 }}" | |
| 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-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: 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-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: 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) | |
| 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" | |
| node 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//\//-} | |
| node scripts/check-release-sbom.ts \ | |
| "evidence/$image-$suffix.syft.json" \ | |
| "evidence/$image-$suffix.spdx.json" \ | |
| "evidence/$image-$suffix.cdx.json" \ | |
| "$digest" "$platform" /tmp/sbom-validation.json | |
| cmp "evidence/$image-$suffix.sbom-validation.json" /tmp/sbom-validation.json | |
| for kind in trivy policy; do | |
| test -s "evidence/$image-$suffix.$kind.json" | |
| done | |
| jq -e --arg ref "$repository@$digest" \ | |
| '.ArtifactName == $ref and (.Results | type == "array")' \ | |
| "evidence/$image-$suffix.license.json" >/dev/null | |
| node scripts/check-release-vulnerabilities.ts "$image" "$platform" "$digest" "$repository" \ | |
| "evidence/$image-$suffix.trivy.json" evidence/vulnerability-policy.json \ | |
| /tmp/policy.json | |
| cmp "evidence/$image-$suffix.policy.json" /tmp/policy.json | |
| 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 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 }} | |
| 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 }} | |
| secrets: inherit |