Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Set up workspace
description: pnpm through corepack (the packageManager field), Node from .nvmrc, a pnpm store cache and a frozen install.
runs:
using: composite
steps:
# Corepack reads the exact pnpm version from package.json's packageManager field.
- name: Enable corepack
shell: bash
run: corepack enable
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
202 changes: 164 additions & 38 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,70 +1,80 @@
name: CI

# Quality gates. A draft runs nothing; a pull request that is ready for review runs every gate on each push.
# Results are summarised in one comment on the pull request (no external services involved).
on:
pull_request:
branches:
- master
- feature/quality-gates-twig-parity-audit
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
push:
branches:
- feature/quality-gates-twig-parity-audit

# A draft run skips every job, so it gets its own group: sharing one with the real run could cancel the
# ready_for_review run.
concurrency:
group: quality-gates-${{ github.event.pull_request.number || github.ref }}${{ github.event.pull_request.draft && '-draft' || '' }}
cancel-in-progress: true

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash

jobs:
gates:
name: Quality gates
static:
name: Lint & Static Checks
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 15
env:
NODE_OPTIONS: --max-old-space-size=6144
steps:
- name: Checkout
uses: actions/checkout@v4

# pnpm must be installed before setup-node so its store cache can resolve.
# No `version` input: it is read from the packageManager field.
- name: Set up pnpm
uses: pnpm/action-setup@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm

- name: Print versions
run: |
node -v
pnpm -v

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm build

- name: Typecheck
run: pnpm typecheck

- name: Set up pnpm and Node
uses: ./.github/actions/setup
- name: Lint
run: pnpm lint

- name: Format check
run: pnpm format:check

- name: Test with coverage
run: pnpm test:coverage

- name: Check manifest
run: pnpm check:manifest

- name: Dead code (knip)
run: pnpm knip

unit:
name: Unit Tests & Coverage
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm and Node
uses: ./.github/actions/setup
- name: Run unit tests and enforce the coverage floor
run: pnpm test:coverage
- name: Write the coverage report
if: always()
run: |
mkdir -p report
{
echo "### Unit tests and coverage"
if [ -f coverage/coverage-summary.json ]; then
echo
echo "| Metric | Covered | Total | % |"
echo "| --- | --- | --- | --- |"
jq -r '.total | to_entries[] | select(.key | IN("lines","statements","functions","branches")) | "| \(.key) | \(.value.covered) | \(.value.total) | \(.value.pct) |"' coverage/coverage-summary.json
else
echo
echo "No coverage summary was produced."
fi
} > report/coverage.md
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
Expand All @@ -73,3 +83,119 @@ jobs:
path: coverage/
if-no-files-found: warn
retention-days: 7
- name: Upload the report section
if: always()
uses: actions/upload-artifact@v4
with:
name: report-coverage
path: report/coverage.md
retention-days: 1

build:
name: Build, Types & Bundle Size
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm and Node
uses: ./.github/actions/setup
- name: Build
run: pnpm build
# Typecheck needs app/routeTree.gen.ts, which the build generates on a clean checkout.
- name: Typecheck
run: pnpm typecheck
- name: Write the bundle size report
if: always()
run: |
mkdir -p report
{
echo "### Bundle size"
if [ -d dist ]; then
echo
echo "Total build output: $(du -sh dist | cut -f1)"
echo
echo "Largest JS and CSS files:"
echo
echo '```'
find dist -type f \( -name '*.js' -o -name '*.css' \) -printf '%s %p\n' | sort -rn | head -15 | awk '{printf "%8.1f KB %s\n", $1/1024, $2}'
echo '```'
else
echo
echo "The build produced no output."
fi
} > report/size.md
- name: Upload the report section
if: always()
uses: actions/upload-artifact@v4
with:
name: report-size
path: report/size.md
retention-days: 1

# One verdict over every gate above: the single check to require in a branch ruleset.
testing-result:
name: Testing Ready
needs: [static, unit, build]
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false)
runs-on: ubuntu-latest
steps:
- name: Every gate passed
env:
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
echo "$NEEDS" | jq -e 'all(.[]; .result == "success")' > /dev/null

# Not part of Testing Ready: a comment problem never blocks a merge.
reports:
name: Test Reports
needs: [static, unit, build]
if: always() && github.event_name == 'pull_request' && github.event.pull_request.draft == false
# The organization's API IP allow list rejects calls from GitHub-hosted runners, so the comment is posted from the
# organization runner when the RUNS_ON variable is set (a JSON runner label or group), else from ubuntu-latest.
runs-on: ${{ vars.RUNS_ON && fromJson(vars.RUNS_ON) || 'ubuntu-latest' }}
timeout-minutes: 10
continue-on-error: true
concurrency:
group: test-reports-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: read
pull-requests: write
steps:
- name: Download the report sections
uses: actions/download-artifact@v4
with:
path: report
pattern: report-*
merge-multiple: true
continue-on-error: true
- name: Comment on the pull request
# The report is always in the job summary too, so a comment the API refuses does not fail the run.
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
NEEDS: ${{ toJSON(needs) }}
run: |
marker='<!-- ci-test-report -->'
{
echo "$marker"
echo "## Test report"
echo
echo "| Gate | Result |"
echo "| --- | --- |"
echo "$NEEDS" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"'
for f in report/coverage.md report/size.md; do
if [ -f "$f" ]; then echo; cat "$f"; fi
done
} > comment.md
cat comment.md >> "$GITHUB_STEP_SUMMARY"
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -n 1)"
if [ -n "$existing" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -F body=@comment.md
else
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@comment.md
fi
14 changes: 14 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# CI quality gates

`.github/workflows/ci.yml` runs on every pull request that is ready for review (and on pushes to the base branch).
It uses only public actions and has no dependency on private repositories, organization variables or external services.

| Job | What it does |
| ---------------------------- | ------------------------------------------------------------------------- |
| `Lint & Static Checks` | lint, format check, manifest check, dead code (knip) |
| `Unit Tests & Coverage` | `pnpm test:coverage`, which enforces the coverage floor |
| `Build, Types & Bundle Size` | build, typecheck (needs the generated route tree), bundle size |
| `Testing Ready` | one verdict over the three jobs above: the check to require |
| `Test Reports` | posts one summary comment on the pull request and updates it on each push |

The `Test Reports` job posts with the workflow token (`pull-requests: write`, no extra credentials) and also writes the report to the job summary. The organization restricts API calls by IP, so it runs on the `RUNS_ON` runner when that variable is set. Reports are plain text in that comment, with no links. The coverage HTML is kept as the `coverage` workflow artifact for 7 days.
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default defineConfig({
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],
include: ['app/**/*.{ts,tsx}'],
exclude: ['app/**/*.d.ts'],
// Coverage floor (ratchet): measured on app/** and rounded down. It only goes up —
Expand Down
Loading