Release #478
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 | |
| # Trunk-based continuous deployment | |
| # Every push to main with feat:/fix:/perf: triggers a release | |
| # Staging deploys immediately, Production requires approval | |
| on: | |
| workflow_run: | |
| workflows: ["CI/CD"] | |
| types: [completed] | |
| branches: [main] | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: {} | |
| jobs: | |
| release: | |
| if: ${{ github.event.workflow_run.conclusion == 'success' }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write # semantic-release pushes the version tag | |
| issues: write # semantic-release closes resolved issues | |
| pull-requests: write # semantic-release comments on merged PRs | |
| outputs: | |
| new_version: ${{ steps.release.outputs.new_release_version }} | |
| released: ${{ steps.release.outputs.new_release_published }} | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| token: ${{ secrets.GH_PAT }} | |
| persist-credentials: false | |
| fetch-depth: 0 | |
| - name: Create Release | |
| id: release | |
| uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2 | |
| with: | |
| extra_plugins: | | |
| @semantic-release/commit-analyzer | |
| @semantic-release/release-notes-generator | |
| @semantic-release/github | |
| conventional-changelog-conventionalcommits | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GH_PAT }} | |
| - name: Summary | |
| if: steps.release.outputs.new_release_published == 'true' | |
| run: | | |
| echo "## 🚀 Released v${{ steps.release.outputs.new_release_version }}" >> $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: | |
| contents: write # commit the digest pin back to main | |
| packages: write # retag images in GHCR | |
| outputs: | |
| agent-pi-digest: ${{ steps.retag.outputs.agent-pi-digest }} | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| token: ${{ secrets.GH_PAT }} | |
| persist-credentials: true | |
| ref: main | |
| - 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.new_version }} | |
| SHA: ${{ github.event.workflow_run.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| manifest_digest() { | |
| printf 'sha256:%s\n' "$(docker buildx imagetools inspect --raw "$1" | sha256sum | awk '{print $1}')" | |
| } | |
| IMAGES=("webapp" "application-server" "webhook-ingest" "agent-pi") | |
| for img in "${IMAGES[@]}"; do | |
| FULL_IMAGE="ghcr.io/ls1intum/hephaestus/$img" | |
| echo "::group::$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 | |
| 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" "$FULL_IMAGE:$SHA" | |
| DST_DIGEST=$(manifest_digest "$FULL_IMAGE:$VERSION") | |
| if [[ "$SRC_DIGEST" != "$DST_DIGEST" ]]; then | |
| echo "::error::Retag changed the digest for $img — refusing to publish." | |
| echo "::error:: source ($FULL_IMAGE:$SHA): $SRC_DIGEST" | |
| echo "::error:: retagged ($FULL_IMAGE:$VERSION): $DST_DIGEST" | |
| exit 1 | |
| fi | |
| if [ "$img" = "agent-pi" ]; then | |
| echo "agent-pi-digest=$DST_DIGEST" >> "$GITHUB_OUTPUT" | |
| fi | |
| 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: Update docker/agent-image-pin.env | |
| env: | |
| DIGEST: ${{ steps.retag.outputs.agent-pi-digest }} | |
| VERSION: ${{ needs.release.outputs.new_version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$DIGEST" ]; then | |
| echo "::error::agent-pi digest is empty — refusing to commit." | |
| exit 1 | |
| fi | |
| REF="ghcr.io/ls1intum/hephaestus/agent-pi@${DIGEST}" | |
| PIN_FILE="docker/agent-image-pin.env" | |
| EXPECTED_LINE="HEPHAESTUS_AGENT_IMAGE_REFERENCE=${REF}" | |
| tmp=$(mktemp) | |
| awk -v ref="$EXPECTED_LINE" ' | |
| /^HEPHAESTUS_AGENT_IMAGE_REFERENCE=/ { print ref; found=1; next } | |
| { print } | |
| END { exit (found ? 0 : 1) } | |
| ' "$PIN_FILE" > "$tmp" || { | |
| echo "::error::$PIN_FILE is missing the HEPHAESTUS_AGENT_IMAGE_REFERENCE= line — refusing to silently no-op." | |
| exit 1 | |
| } | |
| mv "$tmp" "$PIN_FILE" | |
| if git diff --quiet -- "$PIN_FILE"; then | |
| echo "agent-pi digest unchanged — skipping commit." | |
| exit 0 | |
| fi | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| git add "$PIN_FILE" | |
| # [skip ci] keeps cicd.yml from re-firing on this commit. | |
| git commit -m "chore(release): pin agent-pi to ${DIGEST} for v${VERSION} [skip ci]" | |
| for attempt in 1 2 3; do | |
| if git push origin HEAD:main; then exit 0; fi | |
| echo "push attempt $attempt failed — fetching + rebasing" | |
| git fetch origin main | |
| git rebase origin/main || { | |
| echo "::error::rebase produced conflicts on $PIN_FILE — investigate manually." | |
| git rebase --abort | |
| exit 1 | |
| } | |
| if ! grep -qxF "$EXPECTED_LINE" "$PIN_FILE"; then | |
| echo "::error::pin line was lost during rebase; refusing to push a no-op." | |
| exit 1 | |
| fi | |
| sleep $((attempt * 5)) | |
| done | |
| echo "::error::push to main failed after 3 attempts" | |
| exit 1 | |
| # Deploy to staging automatically after images are tagged | |
| deploy-staging: | |
| needs: [release, tag-images] | |
| if: needs.release.outputs.released == 'true' | |
| uses: ./.github/workflows/deploy-staging.yml | |
| with: | |
| image-tag: ${{ needs.release.outputs.new_version }} | |
| deploy-app: true | |
| deploy-core: false | |
| deploy-proxy: false | |
| secrets: inherit | |
| # Trigger production deployment (approval happens in deploy-prod.yml) | |
| 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.new_version }}', | |
| 'deploy-app': 'true', | |
| 'deploy-core': 'false', | |
| 'deploy-proxy': 'false' | |
| } | |
| }); | |
| console.log('✅ Production deployment triggered for v${{ needs.release.outputs.new_version }}'); | |
| - name: Summary | |
| run: | | |
| echo "## 🚀 Production Deployment Triggered" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Version **v${{ needs.release.outputs.new_version }}** 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 |