mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
ci: overhaul CI workflows for reliability and maintainability
- add CODEOWNERS assigning .github/ to @neonsolstice - configure automated updates for npm, docker, and github-actions ecosystems - replace custom change-detection bash script with dorny/paths-filter@v4 - consolidate lint, typecheck, and test into a single checks matrix job - gate per-package change detection via has-changes step (matrix unavailable in job-level if) - fix duplicate actions/setup-node call in setup-node-pnpm composite action - scope pnpm store cache key by pnpm version to prevent stale cross-version cache hits - pin all action references to commit SHAs - fix container-image concurrency group to use commit SHA across all trigger types - add timeout-minutes to all jobs - document fail-fast: false rationale and checks: write trade-off
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/.github/ @neonsolstice
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Setup Node and pnpm
|
||||
description: Set up Node.js, activate pnpm via Corepack, and optionally install dependencies.
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
required: false
|
||||
default: "24"
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via Corepack.
|
||||
required: false
|
||||
default: "10"
|
||||
install:
|
||||
description: Whether to run pnpm install.
|
||||
required: false
|
||||
default: "true"
|
||||
cache:
|
||||
description: Whether to enable pnpm dependency caching.
|
||||
required: false
|
||||
default: "true"
|
||||
cache-dependency-path:
|
||||
description: Path(s) used to generate the cache key.
|
||||
required: false
|
||||
default: "pnpm-lock.yaml"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Enable Corepack and activate pnpm
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate
|
||||
|
||||
# pnpm must be on PATH before its store path can be resolved for cache keying,
|
||||
# so caching is configured in a separate step after Corepack activates pnpm.
|
||||
- name: Get pnpm store directory
|
||||
if: ${{ inputs.cache == 'true' }}
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
if: ${{ inputs.cache == 'true' }}
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store-${{ hashFiles(inputs.cache-dependency-path) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store-
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install == 'true' }}
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: "auto"
|
||||
groups:
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
update-types: ["minor", "patch"]
|
||||
development-dependencies:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ["*"]
|
||||
@@ -0,0 +1,218 @@
|
||||
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)) || 'CI - manual' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24"
|
||||
PNPM_VERSION: "10"
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
|
||||
detect-changes:
|
||||
name: Detect Changed Paths
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dependency-review]
|
||||
timeout-minutes: 10
|
||||
if: always() && (needs.dependency-review.result == 'success' || needs.dependency-review.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 dispatch runs all scopes
|
||||
id: manual
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
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'
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
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 }}"
|
||||
|
||||
checks:
|
||||
name: ${{ matrix.check }} (${{ matrix.package }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dependency-review, detect-changes]
|
||||
timeout-minutes: 20
|
||||
# matrix context is not available in job-level if; per-package change filtering
|
||||
# is done via the has-changes gate step below.
|
||||
if: >
|
||||
always() &&
|
||||
(needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') &&
|
||||
needs.detect-changes.result == 'success'
|
||||
strategy:
|
||||
# All matrix cells run independently so all failures are visible in one pass.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [server, client]
|
||||
check: [lint, typecheck, test]
|
||||
permissions:
|
||||
contents: read
|
||||
# checks: write is only used by publish-unit-test-result-action in test matrix cells.
|
||||
# Lint and typecheck cells receive this permission too due to the shared job definition.
|
||||
checks: write
|
||||
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 }}
|
||||
|
||||
- name: Run lint
|
||||
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'lint'
|
||||
shell: bash
|
||||
run: pnpm --filter "${{ matrix.package }}" lint:check
|
||||
|
||||
- name: Run typecheck
|
||||
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'typecheck'
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
if [[ "$PACKAGE" == "server" ]]; then
|
||||
pnpm --filter server type-check
|
||||
else
|
||||
pnpm --filter @projectx/types build
|
||||
pnpm run typecheck:client:baseline
|
||||
fi
|
||||
|
||||
- name: Prepare test report directory
|
||||
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test'
|
||||
shell: bash
|
||||
run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}"
|
||||
|
||||
- name: Run tests
|
||||
if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test'
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml
|
||||
run: |
|
||||
if [[ "$PACKAGE" == "server" ]]; then
|
||||
JEST_JUNIT_OUTPUT_FILE="$JUNIT_OUTPUT" pnpm --filter server exec jest --ci --reporters=default --reporters=jest-junit
|
||||
else
|
||||
pnpm --filter client exec vitest --run --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish test report annotations
|
||||
if: >
|
||||
always() &&
|
||||
steps.has-changes.outputs.run == 'true' &&
|
||||
matrix.check == 'test' &&
|
||||
(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' &&
|
||||
matrix.check == 'test' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
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, detect-changes, checks]
|
||||
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 "detect-changes=${{ needs.detect-changes.result }}"
|
||||
echo "checks=${{ needs.checks.result }}"
|
||||
@@ -0,0 +1,120 @@
|
||||
name: Container Image
|
||||
run-name: ${{ github.event_name == 'workflow_run' && format('Image - {0} ({1})', github.event.workflow_run.head_commit.message, github.event.workflow_run.head_branch) || format('Image - {0}', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
# Use the commit SHA across all trigger types so a workflow_run completion and a
|
||||
# simultaneous tag push for the same commit share one concurrency slot and cancel correctly.
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build and Push Image
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Resolve source and image tags
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
GH_REF_NAME: ${{ github.ref_name }}
|
||||
GH_SHA: ${{ github.sha }}
|
||||
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
# SHA resolution mirrors the concurrency group logic at the top of this workflow.
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then
|
||||
echo "workflow_dispatch is only supported from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
SHA="$WORKFLOW_RUN_HEAD_SHA"
|
||||
else
|
||||
SHA="$GH_SHA"
|
||||
fi
|
||||
|
||||
SHORT_SHA="${SHA:0:12}"
|
||||
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||
|
||||
PRIMARY_TAG="$GH_REF_NAME"
|
||||
|
||||
echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "${IMAGE}:${PRIMARY_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.vars.outputs.checkout_ref }}
|
||||
|
||||
- name: Verify CI succeeded for tag commit
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
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" \
|
||||
--jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \
|
||||
| awk '{sum += $1} END {print sum}')
|
||||
if [[ "${success_count:-0}" -lt 1 ]]; then
|
||||
echo "No successful CI run found for tag commit ${COMMIT_SHA}. Refusing to publish image."
|
||||
exit 1
|
||||
fi
|
||||
echo "CI verification passed: ${success_count} successful run(s) found."
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
id: build_push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.vars.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
Reference in New Issue
Block a user