mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
d7556011df
Update package manager configuration to pnpm 11.22.0 and Node.js requirement to Node 26 across Dockerfile, GitHub Actions workflows, root package.json, and workspace configurations. Upgrade dependencies, update GitHub Action versions, and remove deprecated scorecard workflow.
410 lines
15 KiB
YAML
410 lines
15 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: "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 "<!-- 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"
|