Skip to content

Release

Release #478

Workflow file for this run

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