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: "26" PNPM_VERSION: "11" jobs: commitlint: name: Lint PR Conventions if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read issues: read pull-requests: read steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.sha }} - name: Classify Crowdin pull request id: crowdin shell: bash env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} BASE_REPO: ${{ github.repository }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} HEAD_BRANCH: ${{ github.head_ref }} run: | set -euo pipefail args=("$PR_BASE_SHA" "$PR_HEAD_SHA" "$BASE_REPO" "$HEAD_REPO" "$HEAD_BRANCH") if git cat-file -e "$PR_BASE_SHA:scripts/classify-crowdin-pr.sh" 2>/dev/null; then is_crowdin="$(git show "$PR_BASE_SHA:scripts/classify-crowdin-pr.sh" | bash -s -- "${args[@]}")" else is_crowdin="$(git show "$PR_HEAD_SHA:scripts/classify-crowdin-pr.sh" | bash -s -- "${args[@]}")" fi echo "is_crowdin=$is_crowdin" >> "$GITHUB_OUTPUT" - name: Checkout pull request uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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: 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 - name: Lint PR commit headers shell: bash env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: pnpm exec commitlint --config commitlint.config.cjs --from "$PR_BASE_SHA" --to "$PR_HEAD_SHA" --verbose - name: Validate referenced issues exist if: github.event.pull_request.user.login != 'dependabot[bot]' && steps.crowdin.outputs.is_crowdin != 'true' shell: bash env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} PR_BRANCH: ${{ github.head_ref }} PR_BODY: ${{ github.event.pull_request.body }} run: | set -euo pipefail validate_issue_exists() { local issue_repo="$1" local issue_number="$2" local reference_context="$3" local response_file local http_code local api_url local state local reference_label response_file="$(mktemp)" api_url="https://api.github.com/repos/${issue_repo}/issues/${issue_number}" http_code="$(curl -sS -o "${response_file}" -w "%{http_code}" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "${api_url}")" if [[ "${issue_repo}" == "${GH_REPO}" ]]; then reference_label="#${issue_number}" else reference_label="${issue_repo}#${issue_number}" fi if [[ "${http_code}" != "200" ]]; then echo "::error::${reference_context} references ${reference_label}, but it does not exist or is not accessible." rm -f "${response_file}" return 1 fi if jq -e '.pull_request != null' "${response_file}" > /dev/null; then echo "::error::${reference_context} references ${reference_label}, but that number is a pull request, not an issue." rm -f "${response_file}" return 1 fi state="$(jq -r '.state // "unknown"' "${response_file}")" echo "${reference_context}: issue ${reference_label} exists (state=${state})." rm -f "${response_file}" } if [[ "${PR_BRANCH}" =~ ^BO-([0-9]+)- ]]; then branch_issue_number="${BASH_REMATCH[1]}" else echo "::error::Unable to extract issue number from branch '${PR_BRANCH}'." exit 1 fi validate_issue_exists "${GH_REPO}" "${branch_issue_number}" "Branch name" closing_keyword_pattern='(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)' issue_reference_pattern="(^|[^[:alnum:]_])${closing_keyword_pattern}([[:space:]]*:[[:space:]]*|[[:space:]]+)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#[1-9][0-9]*" issue_matches="$(printf '%s\n' "${PR_BODY}" | grep -Eio "${issue_reference_pattern}" || true)" if [[ -z "${issue_matches}" ]]; then echo "::error::PR description must include at least one GitHub closing-keyword issue reference (for example: 'Closes #123', 'Fixes: #123', or 'RESOLVED owner/repo#123')." exit 1 fi issue_refs="$(printf '%s\n' "${issue_matches}" \ | tr '[:upper:]' '[:lower:]' \ | sed -E 's/^[^a-z0-9_]*//' \ | sed -E "s/^${closing_keyword_pattern}([[:space:]]*:[[:space:]]*|[[:space:]]+)//" \ | sort -u)" while IFS= read -r issue_ref; do [[ -z "${issue_ref}" ]] && continue if [[ "${issue_ref}" =~ ^([a-z0-9_.-]+/[a-z0-9_.-]+)#([1-9][0-9]*)$ ]]; then issue_repo="${BASH_REMATCH[1]}" issue_number="${BASH_REMATCH[2]}" elif [[ "${issue_ref}" =~ ^#([1-9][0-9]*)$ ]]; then issue_repo="${GH_REPO}" issue_number="${BASH_REMATCH[1]}" else echo "::error::Unable to parse issue reference '${issue_ref}' in PR description." exit 1 fi validate_issue_exists "${issue_repo}" "${issue_number}" "PR description" done <<< "${issue_refs}" 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.release.outputs.tag }} - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.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"