Files
bookorbit/.github/workflows/ci.yml
T
neon befe6ef466 ci: upload codecov coverage for fork PRs
Allow public fork pull requests to upload coverage without repository secrets while keeping tokened uploads for trusted runs.

Use Codecov v6 so the action runs on Node 24.
2026-06-02 15:18:16 +00:00

738 lines
28 KiB
YAML

name: CI
run-name: >-
${{ (github.event_name == 'push' && format('CI - {0} ({1})', github.event.head_commit.message, github.ref_name)) || (github.event_name == 'pull_request' && format('CI - PR #{0}: {1}', github.event.pull_request.number, github.event.pull_request.title)) || (github.event_name == 'schedule' && 'CI - nightly') || 'CI - manual' }}
on:
workflow_dispatch:
schedule:
- cron: "17 4 * * *"
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
dependency-review:
# Enable only when repository security analysis supports dependency review.
if: github.event_name == 'pull_request' && vars.ENABLE_DEPENDENCY_REVIEW == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
pr-conventions:
name: 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: Validate PR branch name
if: github.event.pull_request.user.login != 'dependabot[bot]'
shell: bash
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
set -euo pipefail
pattern='^BO-[0-9]+-[a-z0-9]+(-[a-z0-9]+)*$'
if [[ ! "$PR_BRANCH" =~ $pattern ]]; then
echo "::error::Branch name '$PR_BRANCH' is invalid."
echo "::error::Use BO-<issue-number>-<short-description> (example: BO-123-fix-reader-sync)."
exit 1
fi
- name: Validate referenced issues exist
if: github.event.pull_request.user.login != 'dependabot[bot]'
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}"
- 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: |
set -euo pipefail
pnpm exec commitlint --config commitlint.config.cjs --from "$PR_BASE_SHA" --to "$PR_HEAD_SHA" --verbose
detect-changes:
name: Detect Changed Paths
runs-on: ubuntu-latest
needs: [dependency-review, pr-conventions]
timeout-minutes: 10
if: >
always() &&
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
(needs.pr-conventions.result == 'success' || needs.pr-conventions.result == 'skipped')
outputs:
server: ${{ steps.manual.outputs.server || steps.changes.outputs.server }}
client: ${{ steps.manual.outputs.client || steps.changes.outputs.client }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Manual and nightly runs include all scopes
id: manual
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
shell: bash
run: |
echo "server=true" >> "$GITHUB_OUTPUT"
echo "client=true" >> "$GITHUB_OUTPUT"
# For push events dorny/paths-filter diffs github.event.before..github.sha.
# New-branch pushes (before = 0000...) are handled by treating all files as changed.
# Force-push edge cases (before SHA no longer reachable) may mark all files changed too.
- name: Compute changed scopes
id: changes
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with:
filters: |
server:
- 'server/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/ci.yml'
client:
- 'client/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/ci.yml'
- name: Print change scope
shell: bash
run: |
echo "server=${{ steps.manual.outputs.server || steps.changes.outputs.server }}"
echo "client=${{ steps.manual.outputs.client || steps.changes.outputs.client }}"
lint:
name: Lint (${{ matrix.package_label }})
runs-on: ubuntu-latest
needs: [dependency-review, detect-changes]
timeout-minutes: 10
if: >
always() &&
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
needs.detect-changes.result == 'success'
strategy:
fail-fast: ${{ github.event_name == 'pull_request' }}
matrix:
package: [server, client]
include:
- package: server
package_label: Server
- package: client
package_label: Client
permissions:
contents: read
steps:
- name: Check for package changes
id: has-changes
shell: bash
env:
PACKAGE: ${{ matrix.package }}
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
run: |
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.has-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
if: steps.has-changes.outputs.run == 'true'
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
prefetch: "true"
- name: Run lint checks
if: steps.has-changes.outputs.run == 'true'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
run: |
if [[ "$PACKAGE" == "server" ]]; then
pnpm --filter server lint:check
else
pnpm --filter client lint:check
fi
- name: Run format checks
if: steps.has-changes.outputs.run == 'true'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
run: |
if [[ "$PACKAGE" == "server" ]]; then
pnpm --filter server format:check
else
pnpm --filter client format:check
fi
typecheck:
name: Type Check (${{ matrix.package_label }})
runs-on: ubuntu-latest
needs: [dependency-review, detect-changes]
timeout-minutes: 10
if: >
always() &&
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
needs.detect-changes.result == 'success'
strategy:
fail-fast: ${{ github.event_name == 'pull_request' }}
matrix:
package: [server, client]
include:
- package: server
package_label: Server
- package: client
package_label: Client
permissions:
contents: read
steps:
- name: Check for package changes
id: has-changes
shell: bash
env:
PACKAGE: ${{ matrix.package }}
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
run: |
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.has-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
if: steps.has-changes.outputs.run == 'true'
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
prefetch: "true"
- name: Run typecheck
if: steps.has-changes.outputs.run == 'true'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
run: |
if [[ "$PACKAGE" == "server" ]]; then
pnpm --filter server type-check
else
pnpm --filter @bookorbit/types build
pnpm --filter client type-check
fi
tests:
name: Tests (${{ matrix.package_label }})
runs-on: ubuntu-latest
needs: [dependency-review, detect-changes]
timeout-minutes: 20
if: >
always() &&
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
needs.detect-changes.result == 'success'
strategy:
fail-fast: ${{ github.event_name == 'pull_request' }}
matrix:
package: [server, client]
include:
- package: server
package_label: Server
- package: client
package_label: Client
permissions:
contents: read
checks: write
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
steps:
- name: Check for package changes
id: has-changes
shell: bash
env:
PACKAGE: ${{ matrix.package }}
SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }}
CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }}
run: |
if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \
[[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.has-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
if: steps.has-changes.outputs.run == 'true'
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
prefetch: "true"
- name: Prepare test report directory
if: steps.has-changes.outputs.run == 'true'
shell: bash
run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}"
- name: Run tests
if: steps.has-changes.outputs.run == 'true'
shell: bash
env:
PACKAGE: ${{ matrix.package }}
JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml
run: |
if [[ "$PACKAGE" == "server" ]]; then
pnpm --filter server exec vitest --run --coverage --reporter=default --reporter=github-actions --reporter=junit --outputFile.junit="$JUNIT_OUTPUT"
else
pnpm --filter client exec vitest --run --coverage --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT"
fi
- name: Upload coverage reports to Codecov
if: steps.has-changes.outputs.run == 'true' && env.CODECOV_TOKEN != ''
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
token: ${{ env.CODECOV_TOKEN }}
files: ${{ matrix.package == 'server' && 'server/coverage/lcov.info' || 'client/coverage/lcov.info' }}
flags: ${{ matrix.package }}
name: ${{ matrix.package }}-coverage
verbose: true
- name: Upload coverage reports to Codecov for fork PR
if: >
steps.has-changes.outputs.run == 'true' &&
env.CODECOV_TOKEN == '' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository &&
github.event.repository.private == false
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ${{ matrix.package == 'server' && 'server/coverage/lcov.info' || 'client/coverage/lcov.info' }}
flags: ${{ matrix.package }}
name: ${{ matrix.package }}-coverage
verbose: true
- name: Publish test report annotations
if: >
always() &&
steps.has-changes.outputs.run == 'true' &&
hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: ${{ matrix.package == 'server' && 'Server Unit Tests' || 'Client Unit Tests' }}
files: ${{ format('test-results/{0}/junit.xml', matrix.package) }}
fail_on: nothing
comment_mode: off
- name: Upload test report artifact
if: >
always() &&
steps.has-changes.outputs.run == 'true' &&
hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-report-${{ matrix.package }}
path: test-results/${{ matrix.package }}
retention-days: 7
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
needs:
- dependency-review
- pr-conventions
- detect-changes
- lint
- typecheck
- tests
timeout-minutes: 5
if: always()
steps:
- name: Fail if any quality job failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "One or more quality jobs failed."
exit 1
- name: Quality checks complete
run: |
echo "dependency-review=${{ needs.dependency-review.result }}"
echo "pr-conventions=${{ needs.pr-conventions.result }}"
echo "detect-changes=${{ needs.detect-changes.result }}"
echo "lint=${{ needs.lint.result }}"
echo "typecheck=${{ needs.typecheck.result }}"
echo "tests=${{ needs.tests.result }}"
e2e-setup:
name: Detect E2E Suites
runs-on: ubuntu-latest
needs: [quality-gate]
if: always() && needs.quality-gate.result == 'success' && github.event_name == 'schedule'
timeout-minutes: 10
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build suite matrix
id: build-matrix
shell: bash
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
GITHUB_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: |
MATRIX=$(node scripts/e2e/select-matrix.mjs)
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
echo "selected_suites=$(echo "$MATRIX" | jq -c .)"
e2e:
needs: [e2e-setup]
if: github.event_name == 'schedule' && needs.e2e-setup.outputs.matrix != '[]'
strategy:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.e2e-setup.outputs.matrix) }}
permissions:
contents: read
checks: write
uses: ./.github/workflows/e2e-runner.yml
with:
suite-id: ${{ matrix.suite.id }}
check-name: "E2E - ${{ matrix.suite.name }}"
artifact-name: "e2e-${{ matrix.suite.id }}-report"
job-timeout-minutes: ${{ matrix.suite.timeout }}
ci-gate:
name: CI Gate
runs-on: ubuntu-latest
needs: [quality-gate, e2e-setup, e2e]
timeout-minutes: 5
if: always()
steps:
- name: Fail if any job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "One or more CI jobs failed or were cancelled."
exit 1
- name: CI complete
run: |
echo "quality-gate=${{ needs.quality-gate.result }}"
echo "e2e-setup=${{ needs.e2e-setup.result }}"
echo "e2e=${{ needs.e2e.result }}"
pr-image-scan:
name: PR Image Scan
runs-on: ubuntu-latest
needs: [ci-gate]
timeout-minutes: 45
if: >
always() &&
github.event_name == 'pull_request' &&
needs.ci-gate.result == 'success'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Prepare PR image tag
id: pr-meta
shell: bash
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
short_sha="${HEAD_SHA:0:12}"
scan_tag="pr-scan-${PR_NUMBER}-${short_sha}"
echo "scan_tag=${scan_tag}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
push: false
load: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.scan_tag }}
platforms: linux/amd64
cache-from: type=gha,scope=ci-yml-pr-image
cache-to: type=gha,mode=max,scope=ci-yml-pr-image
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.scan_tag }}
format: table
vuln-type: os,library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
pr-image-publish:
name: PR Image Publish
runs-on: ubuntu-latest
needs: [ci-gate, pr-image-scan]
timeout-minutes: 45
if: >
always() &&
github.event_name == 'pull_request' &&
needs.ci-gate.result == 'success' &&
needs.pr-image-scan.result == 'success' &&
github.event.pull_request.head.repo.full_name == github.repository &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Prepare PR publish tag
id: pr-meta
shell: bash
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
short_sha="${HEAD_SHA:0:12}"
publish_tag="pr-${PR_NUMBER}-${short_sha}"
echo "publish_tag=${publish_tag}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN != '' && secrets.GHCR_TOKEN || github.token }}
- name: Build and push PR image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.pr-meta.outputs.publish_tag }}
platforms: linux/amd64
cache-from: type=gha,scope=ci-yml-pr-image
cache-to: type=gha,mode=max,scope=ci-yml-pr-image
ci-performance:
name: CI Performance
runs-on: ubuntu-latest
needs: [ci-gate]
if: always()
permissions:
actions: read
contents: read
steps:
- name: Compare duration with previous successful run
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
CURRENT_JSON=$(gh api "repos/${GH_REPO}/actions/runs/${RUN_ID}")
CURRENT_STARTED=$(echo "$CURRENT_JSON" | jq -r '.run_started_at // .created_at')
CURRENT_UPDATED=$(echo "$CURRENT_JSON" | jq -r '.updated_at')
CURRENT_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$CURRENT_STARTED" "$CURRENT_UPDATED")
PREVIOUS_JSON=$(gh api "repos/${GH_REPO}/actions/workflows/ci.yml/runs?per_page=30&event=${EVENT_NAME}&status=completed")
PREVIOUS_STARTED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | (.run_started_at // .created_at)' | head -n1)
PREVIOUS_UPDATED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | .updated_at' | head -n1)
{
echo "## CI Runtime"
echo ""
echo "- Event: \`${EVENT_NAME}\`"
echo "- Current run duration: \`${CURRENT_DURATION}s\`"
} >> "$GITHUB_STEP_SUMMARY"
if [[ -z "$PREVIOUS_STARTED" || -z "$PREVIOUS_UPDATED" ]]; then
echo "- Baseline: unavailable (no previous successful run for this event)" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
PREVIOUS_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$PREVIOUS_STARTED" "$PREVIOUS_UPDATED")
RATIO=$(node -e 'const [current, previous] = process.argv.slice(1).map(Number); if (previous === 0) { console.log("N/A"); process.exit(0); } console.log((current / previous).toFixed(2));' "$CURRENT_DURATION" "$PREVIOUS_DURATION")
echo "- Previous successful run: \`${PREVIOUS_DURATION}s\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Ratio (current/previous): \`${RATIO}\`" >> "$GITHUB_STEP_SUMMARY"
if [[ "$RATIO" != "N/A" ]] && node -e 'process.exit(Number(process.argv[1]) > 1.2 ? 0 : 1)' "$RATIO"; then
echo "::warning::CI duration regression detected: current=${CURRENT_DURATION}s previous=${PREVIOUS_DURATION}s ratio=${RATIO}"
fi