name: Release run-name: >- ${{ github.event_name == 'workflow_dispatch' && format('Release - {0}', github.ref_name) || format('Release checks - PR #{0}', github.event.pull_request.number) }} on: pull_request: branches: [main] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'release' || format('pr-{0}', github.event.pull_request.number) }} cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} permissions: read-all env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24" PNPM_VERSION: "10" jobs: commitlint: name: Lint PR Title if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read pull-requests: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js and pnpm uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} - name: Lint PR title shell: bash env: PR_TITLE: ${{ github.event.pull_request.title }} run: printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --config commitlint.config.cjs release-dry-run: name: Release Dry Run if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write issues: write pull-requests: write steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up Node.js and pnpm uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} - name: Semantic release dry run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm exec semantic-release --dry-run --no-ci release: name: Publish Release if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 permissions: actions: read contents: write issues: write pull-requests: write packages: read outputs: published: ${{ steps.semantic.outputs.new_release_published }} version: ${{ steps.semantic.outputs.new_release_version }} tag: ${{ steps.semantic.outputs.new_release_git_tag }} steps: - name: Guard against non-main branch shell: bash env: GH_REF: ${{ github.ref }} run: | if [[ "$GH_REF" != "refs/heads/main" ]]; then echo "::error::Release workflow must be dispatched from main. Current ref: $GH_REF" exit 1 fi - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Verify CI success for current SHA shell: bash env: GH_TOKEN: ${{ github.token }} COMMIT_SHA: ${{ github.sha }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail echo "Checking CI status for commit ${COMMIT_SHA}..." success_count=$(gh api --paginate \ "repos/${GH_REPO}/actions/workflows/ci.yml/runs?head_sha=${COMMIT_SHA}&status=completed&event=push&branch=main" \ --jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \ | awk '{sum += $1} END {print sum}') if [[ "${success_count:-0}" -lt 1 ]]; then echo "::error::No successful CI run found for commit ${COMMIT_SHA}. Refusing to release." exit 1 fi echo "CI verification passed: ${success_count} successful run(s) found." - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Verify SHA image exists in GHCR shell: bash env: COMMIT_SHA: ${{ github.sha }} IMAGE_NAME: ${{ github.repository }} run: | set -euo pipefail SHORT_SHA="${COMMIT_SHA:0:12}" SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${SHORT_SHA}" if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then echo "::error::Multi-arch image sha-${SHORT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." exit 1 fi echo "Image sha-${SHORT_SHA} verified in GHCR." - name: Set up Node.js and pnpm uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} - name: Run semantic-release id: semantic shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail BEFORE_TAG=$(git tag --sort=-v:refname | head -1 || true) pnpm exec semantic-release git fetch --tags --quiet AFTER_TAG=$(git tag --sort=-v:refname | head -1 || true) if [[ -n "$AFTER_TAG" && "$AFTER_TAG" != "$BEFORE_TAG" ]]; then VERSION="${AFTER_TAG#v}" echo "new_release_published=true" >> "$GITHUB_OUTPUT" echo "new_release_version=${VERSION}" >> "$GITHUB_OUTPUT" echo "new_release_git_tag=${AFTER_TAG}" >> "$GITHUB_OUTPUT" else echo "new_release_published=false" >> "$GITHUB_OUTPUT" fi - name: Release summary if: steps.semantic.outputs.new_release_published == 'true' shell: bash env: VERSION: ${{ steps.semantic.outputs.new_release_version }} TAG: ${{ steps.semantic.outputs.new_release_git_tag }} run: | echo "## Release Published" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY" echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY" docker-promote: name: Promote Docker Image needs: [release] if: needs.release.outputs.published == 'true' runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: write packages: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout release tag uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release.outputs.tag }} - name: Set up QEMU uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push versioned image id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ./Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest build-args: APP_VERSION=v${{ needs.release.outputs.version }} cache-from: | type=gha,scope=container-image-yml-amd64 type=gha,scope=container-image-yml-arm64 - name: Enrich release notes shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.release.outputs.tag }} VERSION: ${{ needs.release.outputs.version }} SHA: ${{ github.sha }} IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail BODY_FILE="$(mktemp)" gh release view "${TAG}" --repo "${GH_REPO}" --json body --jq '.body' > "${BODY_FILE}" if grep -q "" "${BODY_FILE}"; then echo "Release metadata already present; skipping." exit 0 fi PREV_TAG=$(gh release list \ --repo "${GH_REPO}" \ --exclude-drafts \ --exclude-pre-releases \ --limit 100 \ --json tagName \ --jq '.[].tagName' \ | awk -v current="${TAG}" '$0 != current {print; exit}') { echo "" echo "" echo "" if [[ -n "${PREV_TAG}" ]]; then echo "[Full diff](https://github.com/${GH_REPO}/compare/${PREV_TAG}...${TAG})" echo "" fi } >> "${BODY_FILE}" gh release edit "${TAG}" --repo "${GH_REPO}" --notes-file "${BODY_FILE}" - name: Docker publish summary shell: bash env: VERSION: ${{ needs.release.outputs.version }} SHA: ${{ github.sha }} run: | echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "- **Version:** \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY" echo "- **APP_VERSION baked:** \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"