mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
e89f4ce0e4
Validate BO-<issue>-<slug> branch names and lint PR titles and commit headers in CI and release workflows. Require GitHub closing-keyword issue references and verify referenced issues exist. Align contributing, release, commit, testing, and policy docs with the enforced behavior and tone.
384 lines
14 KiB
YAML
384 lines
14 KiB
YAML
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 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@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: 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
|
|
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@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@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.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 "<!-- release-metadata -->" "${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 "<!-- release-metadata -->"
|
|
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"
|