Release #550
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] | |
| # Key per commit so distinct releases queue independently — a global group would | |
| # let a newer run cancel an older release that is still pending. | |
| concurrency: | |
| group: release-${{ github.event.workflow_run.head_sha }} | |
| 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| 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 | |
| # Idempotent: if the Release already exists, this version is done. (A | |
| # release whose downstream jobs half-failed is recovered by deleting the | |
| # Release + its tag and re-running — not by silently re-releasing an old | |
| # version, which would re-deploy it.) | |
| if gh release view "$TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then | |
| echo "Release $TAG already exists — nothing to cut." | |
| echo "released=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| 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/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 "-------------------------" | |
| # Creates the tag at $SHA and the Release atomically (no separate | |
| # push, so there is no tag-without-Release window on a mid-step failure). | |
| gh release create "$TAG" \ | |
| --title "$TAG" \ | |
| --notes-file "$NOTES" \ | |
| --target "$SHA" \ | |
| --repo "${{ github.repository }}" | |
| { | |
| 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 "## 🚀 Released ${{ steps.cut.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Staging**: Deploying automatically" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Production**: Waiting for approval" >> $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 pin asset) | |
| outputs: | |
| agent-pi-digest: ${{ steps.retag.outputs.agent-pi-digest }} | |
| application-server-digest: ${{ steps.retag.outputs.application-server-digest }} | |
| steps: | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Retag images and capture digests | |
| id: retag | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| CHANNEL: ${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }} | |
| 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' | |
| } | |
| IMAGES=("webapp" "application-server" "agent-pi" "release-pin-fetcher" "postgres") | |
| # 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" | |
| docker buildx imagetools create \ | |
| -t "$FULL_IMAGE:$VERSION" \ | |
| -t "$FULL_IMAGE:$CHANNEL" \ | |
| -t "$FULL_IMAGE:latest" \ | |
| "$FULL_IMAGE:$SHA" | |
| for tag in "$VERSION" "$CHANNEL" "latest"; do | |
| DST_DIGEST=$(manifest_digest "$FULL_IMAGE:$tag") | |
| if [[ "$SRC_DIGEST" != "$DST_DIGEST" ]]; then | |
| echo "::error::Retag changed the digest for $img:$tag — refusing to publish." | |
| exit 1 | |
| fi | |
| done | |
| 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 | |
| - name: Smoke-test agent-pi | |
| env: | |
| IMAGE: ghcr.io/ls1intum/hephaestus/agent-pi@${{ steps.retag.outputs.agent-pi-digest }} | |
| run: | | |
| set -euo pipefail | |
| docker pull "$IMAGE" | |
| docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'bun --version && node --version' | |
| - name: Write release pin asset | |
| id: pin | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| AGENT_DIGEST: ${{ steps.retag.outputs.agent-pi-digest }} | |
| run: | | |
| set -euo pipefail | |
| ASSET="release-v${VERSION}.yaml" | |
| cat > "$ASSET" <<EOF | |
| hephaestus: | |
| agent: | |
| image: | |
| reference: ghcr.io/ls1intum/hephaestus/agent-pi@${AGENT_DIGEST} | |
| EOF | |
| echo "asset-path=$ASSET" >> "$GITHUB_OUTPUT" | |
| cat "$ASSET" | |
| - name: Generate subject checksums | |
| id: subjects | |
| env: | |
| ASSET: ${{ steps.pin.outputs.asset-path }} | |
| APP_DIGEST: ${{ steps.retag.outputs.application-server-digest }} | |
| AGENT_DIGEST: ${{ steps.retag.outputs.agent-pi-digest }} | |
| run: | | |
| # shasum-format: '<hex> <name>'. Names must be plain identifiers | |
| # (no URL chars) — actions/attest forwards them verbatim into the | |
| # in-toto subject.name field. | |
| set -euo pipefail | |
| { | |
| printf '%s %s\n' "${APP_DIGEST#sha256:}" "application-server" | |
| printf '%s %s\n' "${AGENT_DIGEST#sha256:}" "agent-pi" | |
| printf '%s %s\n' "$(sha256sum "$ASSET" | awk '{print $1}')" "$ASSET" | |
| } > subjects.sha256 | |
| cat subjects.sha256 | |
| - name: Attest release pin | |
| # Version belongs inside the purl, not as a top-level field. | |
| # https://github.com/in-toto/attestation/blob/main/spec/predicates/release.md | |
| 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: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Sign release pin asset | |
| 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) | |
| # Assert here, at build time, exactly what docker/compose.app.yaml's | |
| # release-pin-fetcher asserts at deploy time. A mismatch (e.g. the OIDC | |
| # ref drifts) fails the release loudly instead of silently hanging every | |
| # production deploy on `cosign verify-blob`. Keep this identity in sync | |
| # with docker/compose.app.yaml. | |
| 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 pin asset 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 | |
| # Release assets are immutable: no --clobber, fail loud on re-run. | |
| gh release upload "$TAG_NAME" \ | |
| "$ASSET" "${ASSET}.sigstore.json" \ | |
| --repo "${{ github.repository }}" | |
| deploy-staging: | |
| needs: [release, tag-images] | |
| if: needs.release.outputs.released == 'true' | |
| uses: ./.github/workflows/deploy-staging.yml | |
| # The called org workflow (ls1intum/.github deploy-docker-compose.yml@main) | |
| # requires these. With the top-level `permissions: {}`, omitting them makes | |
| # workflow COMPILATION fail ("requesting 'contents: read' but only allowed | |
| # 'none'") — a startup_failure that killed every release since June 2026. | |
| permissions: | |
| contents: read | |
| packages: read | |
| with: | |
| image-tag: ${{ needs.release.outputs.version }} | |
| deploy-app: true | |
| deploy-core: false | |
| deploy-proxy: false | |
| 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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| 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.version }}', | |
| 'deploy-app': 'true', | |
| 'deploy-core': 'false', | |
| 'deploy-proxy': 'false' | |
| } | |
| }); | |
| 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 |