Repository navigation
feat(security): classify every mutating route and pace the worst ones #1334
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| merge_group: | |
| # A manual run for a branch that has no PR, and a nightly run on main so a | |
| # dependency, runner image or upstream action change is not discovered by the | |
| # next PR. 19:17 UTC is 03:17 Asia/Shanghai. | |
| workflow_dispatch: | |
| schedule: | |
| - cron: '17 19 * * *' | |
| # The workflow default is the narrowest set, and every job repeats it: a | |
| # job-level `permissions:` replaces this block instead of merging with it, so a | |
| # job added to this file later cannot inherit a write scope by accident, and a | |
| # job that needs more has to say so in its own diff. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # A superseded pull request run is waste; a run on main is the record of a | |
| # commit, so it is never cancelled by a later one. | |
| # | |
| # #574: GitHub keeps one running and one pending run per group, and a later | |
| # arrival replaces the pending one — the replaced run is marked `cancelled` | |
| # before any job is created, so its commit carries no `ci-ok` at all. Two | |
| # merges landing seconds apart hit exactly that (runs 43435325525 and | |
| # 43435335986, both `cancelled` with zero jobs), so a `main` push keys its | |
| # group by the commit as well. A pull request and a merge queue entry keep | |
| # the ref-only group: superseding is the point there, and `merge_group` must | |
| # keep queueing behind its own ref. | |
| group: ci-${{ github.ref }}${{ github.event_name == 'push' && format('-{0}', github.sha) || '' }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # Classify the changed paths once, before any runner is allocated for a heavy | |
| # job. A job that cannot learn anything from this diff is skipped by its own | |
| # job-level `if`; it does not start, install dependencies and then skip. The | |
| # classifier is conservative: an unknown path or an unreadable diff runs the | |
| # full gate, and a skipped job is accepted by `ci-ok` only when the matching | |
| # output is false. | |
| changes: | |
| name: changes | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| outputs: | |
| docsOnly: ${{ steps.classify.outputs.docsOnly }} | |
| source: ${{ steps.classify.outputs.source }} | |
| frontend: ${{ steps.classify.outputs.frontend }} | |
| deps: ${{ steps.classify.outputs.deps }} | |
| db: ${{ steps.classify.outputs.db }} | |
| docker: ${{ steps.classify.outputs.docker }} | |
| workflows: ${{ steps.classify.outputs.workflows }} | |
| codeql: ${{ steps.classify.outputs.codeql }} | |
| deploy: ${{ steps.classify.outputs.deploy }} | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # A pull request needs the whole range for `git diff base...head`; a | |
| # push or schedule classifies every flag as true without reading git. | |
| fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && 0 || 1 }} | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - name: Classify the changed paths | |
| id: classify | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| PR_BASE: ${{ github.event.pull_request.base.sha }} | |
| PR_HEAD: ${{ github.event.pull_request.head.sha }} | |
| PR_BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| GROUP_BASE: ${{ github.event.merge_group.base_sha }} | |
| GROUP_HEAD: ${{ github.event.merge_group.head_sha }} | |
| GROUP_BASE_REF: ${{ github.event.merge_group.base_ref }} | |
| run: | | |
| set -euo pipefail | |
| # #640: the pull request that introduces `scripts/gate-integrity.mjs` | |
| # runs this job with a base branch that predates the file. The test is | |
| # against the base tree, so a diff cannot decide whether the summary | |
| # runs; without the file there is no summary to write, and the merge | |
| # still needs its T3 PASS. | |
| gate_integrity() { | |
| if [ -f base/scripts/gate-integrity.mjs ]; then | |
| node base/scripts/gate-integrity.mjs "$@" | |
| else | |
| echo "::notice::gate-integrity is not on the base branch yet; this change introduces it" | |
| fi | |
| } | |
| ref="" | |
| case "$EVENT" in | |
| pull_request) base="$PR_BASE"; head="$PR_HEAD"; ref="$PR_BASE_REF" ;; | |
| merge_group) base="$GROUP_BASE"; head="$GROUP_HEAD"; ref="$GROUP_BASE_REF" ;; | |
| *) node base/scripts/ci-changes.mjs --all; gate_integrity --all; exit 0 ;; | |
| esac | |
| # #752: a branch cut before the latest `main` names a base commit that | |
| # this checkout does not carry, so the two-commit diff could not be | |
| # computed and the step fell through to `--all`: every gate ran, and | |
| # `codeql-required` then spent its full bound waiting for an analysis | |
| # CodeQL never starts for an ignored path. The script fetches the base | |
| # and retries; the conservative every-gate answer is still its last | |
| # resort, and it lives there where the suite can drive it. | |
| node base/scripts/ci-changes.mjs --repo . --git-base "$base" --git-head "$head" --base-ref "$ref" | |
| # #640: the reviewer must see that the gate is inside the diff this gate | |
| # judges; an unreadable diff fails closed instead of reporting a clean one. | |
| gate_integrity --repo . --git-base "$base" --git-head "$head" --base-ref "$ref" | |
| lint: | |
| name: lint | |
| needs: changes | |
| if: needs.changes.outputs.source == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| # Dependency lifecycle scripts are code execution from an untrusted | |
| # package before any of ours runs. Lint needs none of them, so it is the | |
| # first job to install with scripts disabled; the other jobs follow one | |
| # at a time with their own measured run (#646). | |
| - run: npm ci --ignore-scripts | |
| - run: npm run lint | |
| typecheck: | |
| name: typecheck | |
| needs: changes | |
| if: needs.changes.outputs.source == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run typecheck | |
| test: | |
| name: test (${{ matrix.node }}) | |
| # Unconditional on purpose: the suite reads `docs/`, `public/`, `deploy/` and | |
| # `.github/` (60 of the 184 files under `test/`), so no diff makes it | |
| # inapplicable. Skipping it turned `ci-ok` green over a red suite for | |
| # docs-only, `public/`-only, `deploy/`-only and workflow-only diffs. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| # #642: `coverage` already runs the whole suite on Node 22, and that is the | |
| # Node 22 execution for the merge decision. A second Node 22 leg here ran | |
| # the same suite again with no instrumented floor and no second signal, so | |
| # the matrix keeps only the supported-version check. `ci-ok` still needs | |
| # `test`; the job name stays `test (<node>)` and its one leg is `test (24)`. | |
| matrix: | |
| node: [24] | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| cache: npm | |
| - run: npm ci | |
| - run: npm test | |
| # The floor is a number in package.json (`test:coverage`), measured at the | |
| # commit that introduced it and then only raised. It runs once, on the Node | |
| # version package.json requires, because two versions measuring the same code | |
| # would only add noise to the number. | |
| coverage: | |
| name: coverage | |
| # Same reason as `test`: the floor measures the suite that reads those trees. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #641: the changed-lines step below diffs the pull request against its | |
| # base, which a depth-1 checkout does not carry. The suite runs from the | |
| # same checkout, so the history is fetched once here. | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - name: Measure and enforce the coverage floor | |
| # `tee` keeps the report for the summary below; the bash shell CI uses is | |
| # `-eo pipefail`, so the threshold's exit code is still the step's. | |
| run: npm run test:coverage 2>&1 | tee coverage.txt | |
| - name: Enforce the changed-lines floor | |
| # #641: the floor above is one number over `src/**`, so a large covered | |
| # tree hides a new untested branch. This step measures only the lines this | |
| # diff adds. Pull request only: a push or schedule has no base and a | |
| # merge-queue entry re-runs the pull request's own tree. | |
| # | |
| # The head is the checked-out commit, never `pull_request.head.sha`: | |
| # this job's checkout carries no `ref:`, so it is `refs/pull/<N>/merge` | |
| # and `coverage.txt` is numbered in the merge tree. `git diff base...HEAD` | |
| # stays the pull request's own changes — the base is the merge's first | |
| # parent — and its line numbers now come from the same tree as the report. | |
| # With the head sha the two disagree whenever the base moved in a file | |
| # the pull request edits, and an uncovered line is reported as covered. | |
| if: github.event_name == 'pull_request' | |
| env: | |
| PR_BASE: ${{ github.event.pull_request.base.sha }} | |
| PR_BODY: ${{ github.event.pull_request.body }} | |
| run: node scripts/coverage-diff.mjs --base "$PR_BASE" --head HEAD --report coverage.txt --floor 80 | |
| - name: Publish the coverage report to the run summary | |
| if: always() | |
| run: | | |
| { | |
| echo '### Coverage' | |
| echo '```' | |
| sed -n '/start of coverage report/,/end of coverage report/p' coverage.txt | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| build: | |
| name: build | |
| needs: changes | |
| if: needs.changes.outputs.source == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run build | |
| # The drill reuses this artifact instead of building a second tree (#643). | |
| # The manifest binds every emitted file to the commit that emitted it, so a | |
| # consumer can refuse an artifact that is not from its own head. The commit | |
| # is `github.sha`, the same value the drill checks, so the two jobs cannot | |
| # disagree about which tree is under test. | |
| - name: Record the commit the artifact was built from | |
| run: node scripts/dist-manifest.mjs write --dir dist --commit "$GITHUB_SHA" | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: fledge-dist | |
| path: dist | |
| retention-days: 7 | |
| # A throwaway app boots at nine widths in both locales, renders the owner, | |
| # account, repository, admin and workspace surfaces, and fails on page or | |
| # element overflow. Screenshots are artifacts, not committed files. | |
| visual: | |
| name: visual | |
| needs: changes | |
| if: needs.changes.outputs.frontend == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run build | |
| - run: npx playwright install --with-deps chromium | |
| - run: npm run visual:matrix | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| if: always() | |
| with: | |
| name: visual-matrix | |
| path: visual-matrix | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| # Build, scan, then run: a build that passes is not an image that starts, and | |
| # an image that starts is not one without a known high-severity package. | |
| docker: | |
| name: docker | |
| needs: changes | |
| if: needs.changes.outputs.docker == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - run: docker build -t fledge:ci . | |
| # Threshold: HIGH and CRITICAL, exit-code 1, unfixed findings ignored so a | |
| # base-image CVE nobody can patch does not brick every pull request. An | |
| # exception goes in .trivyignore.yaml, one entry with an expiry, never a | |
| # severity downgrade. | |
| # | |
| # `skip-dirs` narrows the scan to what this repository ships. The npm CLI in | |
| # the base image vendors its own dependency tree under | |
| # usr/local/lib/node_modules/npm; nothing at runtime executes it (the | |
| # container runs `node dist/index.js`) and no change here can patch it — | |
| # those versions move only when the node image moves. Scanning it reported | |
| # ten HIGH advisories in npm's bundled brace-expansion, pacote and sigstore | |
| # that this repository cannot fix, which would keep the gate red forever and | |
| # teach everyone to ignore it. Everything else stays in scope: OS packages | |
| # and /app/node_modules. | |
| - name: Scan the image for known vulnerabilities | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: fledge:ci | |
| format: table | |
| exit-code: '1' | |
| severity: 'HIGH,CRITICAL' | |
| ignore-unfixed: true | |
| skip-dirs: usr/local/lib/node_modules/npm | |
| trivyignores: .trivyignore.yaml | |
| - name: Run the image and assert /healthz | |
| run: | | |
| set -euo pipefail | |
| # The image runs with NODE_ENV=production, so src/config.ts refuses to | |
| # start without these. Generate every one of them per run: a committed | |
| # fixture would be a secret, and a real OAuth pair would be a credential | |
| # this job has no business holding. `ALLOW_TOKEN_LOGIN_ONLY` is the | |
| # documented way to run without an OAuth app; the runner stays on its | |
| # default `mock` mode and mail on its default `console` transport, so no | |
| # outbound call or SMTP account is involved. | |
| random() { node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('$1'))"; } | |
| docker run -d --name fledge-smoke \ | |
| -e SESSION_SECRET="$(random hex)" \ | |
| -e TOKEN_ENCRYPTION_KEY="$(random base64)" \ | |
| -e METRICS_TOKEN="$(random hex)" \ | |
| -e ALLOW_TOKEN_LOGIN_ONLY=true \ | |
| -e DATA_DIR=/data \ | |
| -e PORT=8080 \ | |
| -p 18080:8080 \ | |
| fledge:ci | |
| cleaned=0 | |
| cleanup() { | |
| if [ "$cleaned" = 0 ]; then docker rm -f fledge-smoke >/dev/null 2>&1 || true; fi | |
| } | |
| trap cleanup EXIT | |
| started=0 | |
| for _ in $(seq 1 30); do | |
| if curl -fsS http://127.0.0.1:18080/healthz -o /tmp/healthz.json; then started=1; break; fi | |
| if [ "$(docker inspect -f '{{.State.Running}}' fledge-smoke 2>/dev/null)" = "false" ]; then break; fi | |
| sleep 1 | |
| done | |
| if [ "$started" = 0 ]; then | |
| echo "::error::the image never answered /healthz" | |
| docker logs fledge-smoke || true | |
| exit 1 | |
| fi | |
| ok="$(jq -r '.ok' /tmp/healthz.json)" | |
| version="$(jq -r '.version // empty' /tmp/healthz.json)" | |
| if [ "$ok" != "true" ]; then | |
| echo "::error::/healthz did not answer ok=true: $(head -c 400 /tmp/healthz.json)" | |
| exit 1 | |
| fi | |
| if [ -z "$version" ]; then | |
| echo "::error::/healthz carries no version: $(head -c 400 /tmp/healthz.json)" | |
| exit 1 | |
| fi | |
| echo "healthz ok, version ${version}" | |
| docker rm -f fledge-smoke >/dev/null 2>&1 || true | |
| cleaned=1 | |
| audit: | |
| name: audit | |
| needs: changes | |
| if: needs.changes.outputs.deps == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| # The published set first, so a runtime advisory is reported as one. | |
| - run: npm audit --omit=dev --audit-level=high | |
| # Then the whole graph: a development dependency runs its install scripts | |
| # on this runner with this token, so "it is only a dev dependency" is not a | |
| # perimeter. | |
| - run: npm audit --audit-level=high | |
| dependency-review: | |
| name: dependency-review | |
| # Reads the pull request's dependency diff, so it has nothing to compare on a | |
| # push, a nightly or a manual run, and nothing of its own in a merge group. | |
| # `ci-ok` allows `skipped` for this job on exactly those events | |
| # (scripts/ci-ok.mjs SKIPPABLE_ON). | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 | |
| with: | |
| fail-on-severity: high | |
| changelog: | |
| name: changelog | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - run: node scripts/changelog.mjs check | |
| version: | |
| name: version | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - run: node scripts/version.mjs check | |
| risk: | |
| name: risk | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - name: Pick the base | |
| id: base | |
| # The choice lives in scripts/lib/risk.mjs (chooseBase) so that | |
| # merge_group measures against its base sha instead of HEAD~1, and so | |
| # test/riskBase.test.ts covers it. The step fetches what it needs. | |
| run: echo "ref=$(node base/scripts/risk-gate.mjs --print-base)" >> "$GITHUB_OUTPUT" | |
| # The branch and the title are the mechanical-release key (#80): only a PR | |
| # whose head is release/vX.Y.Z and whose title is exactly "release: vX.Y.Z" | |
| # is classified as the mechanical class. A PR that merely touches the same | |
| # five files keeps its computed T3 and the PASS that comes with it. | |
| - name: Compute the tier | |
| env: | |
| PR_BRANCH: ${{ github.event.pull_request.head.ref }} | |
| PR_TITLE: ${{ github.event.pull_request.title }} | |
| BASE_REF: ${{ steps.base.outputs.ref }} | |
| run: node base/scripts/risk-gate.mjs --repo . --base "$BASE_REF" --branch "$PR_BRANCH" --title "$PR_TITLE" | |
| # The drill imports `dist/db.js` and the checked-out `scripts/` only: no file | |
| # under `node_modules` is loaded at runtime, so a second `npm ci` and | |
| # `npm run build` bought a deferred defect at best and two minutes of runner | |
| # time at worst (#643). The build artifact is reused, and before the drill runs | |
| # `scripts/dist-manifest.mjs verify` proves the artifact was emitted by the | |
| # build job for this exact commit: a stale, partial, edited or foreign artifact | |
| # fails here instead of standing in for this tree. A missing artifact fails at | |
| # the download step — there is no fallback rebuild, because that fallback is | |
| # what would hide a broken build job. | |
| restore-drill: | |
| name: restore-drill | |
| needs: [changes, build] | |
| if: needs.changes.outputs.db == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| name: fledge-dist | |
| path: dist | |
| - name: Prove the artifact came from this commit | |
| run: node scripts/dist-manifest.mjs verify --dir dist --commit "$GITHUB_SHA" | |
| - run: node scripts/restore-drill-ci.mjs | |
| action-pins: | |
| name: action-pins | |
| needs: changes | |
| if: needs.changes.outputs.workflows == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| # No `npm ci`: the checker is plain node and reads only the workflow files, | |
| # so a floating tag fails before any dependency install can run. | |
| - run: node base/scripts/action-pins.mjs --workflows .github/workflows | |
| # actionlint reads the YAML, the expressions and the shell syntax. It has no | |
| # taint analysis, so `${{ github.event.pull_request.title }}` inside `run:` is | |
| # well-formed at every one of those levels and still executes the text whoever | |
| # opened the pull request wrote. scripts/workflow-injection.mjs is that check. | |
| workflow-lint: | |
| name: workflow-lint | |
| needs: changes | |
| if: needs.changes.outputs.workflows == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| # Plain node over the workflow files, so it runs in seconds and before any | |
| # dependency install could be the thing that fails. | |
| - name: Reject a workflow expression inside a shell script | |
| run: node base/scripts/workflow-injection.mjs --workflows .github/workflows --allowlist base/.github/agent/workflow-injection-allowlist.json | |
| - name: Lint the workflows | |
| uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 | |
| with: | |
| # The action's own default is `version: latest`, so a pinned action would | |
| # still download whatever actionlint is newest when it runs. Pin it. | |
| version: '1.7.12' | |
| # Four shellcheck findings predate this job and live in | |
| # daily-release.yml, which the release work (#80) owns and this pull | |
| # request may not edit: SC2010 and SC2126 at line 61, SC2016 at 84 and | |
| # 138. actionlint's -ignore matches the message text, which does not | |
| # carry the file name, so this silences exactly those three rule ids and | |
| # nothing else — and today they fire nowhere but that file (checked by | |
| # running actionlint 1.7.12 without the flag). One token per flag: the | |
| # action splits `flags` on whitespace and keeps quotes literally, so a | |
| # single quoted pattern with spaces does not reach actionlint. Remove | |
| # these together with #80's fix to daily-release.yml. | |
| flags: '-ignore SC2010 -ignore SC2126 -ignore SC2016' | |
| # gitleaks runs as a checksum-pinned binary rather than an action: the action | |
| # is licence-gated and chooses its own scan range, and this job has to prove a | |
| # range. fetch-depth: 0 is the point — a secret committed on the branch and | |
| # deleted before the head is in neither the head tree nor the pull request | |
| # diff, and only the commit range still holds it. | |
| secret-scan: | |
| name: secret-scan | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| env: | |
| GITLEAKS_VERSION: 8.28.0 | |
| GITLEAKS_SHA256: a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install gitleaks (pinned version, verified checksum) | |
| run: | | |
| set -euo pipefail | |
| archive="/tmp/gitleaks_${GITLEAKS_VERSION}.tar.gz" | |
| curl -sSfL -o "$archive" \ | |
| "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | |
| echo "${GITLEAKS_SHA256} ${archive}" | sha256sum -c - | |
| tar xzf "$archive" -C /usr/local/bin gitleaks | |
| - name: Scan the commit range | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| PR_BASE: ${{ github.event.pull_request.base.sha }} | |
| PR_HEAD: ${{ github.event.pull_request.head.sha }} | |
| GROUP_BASE: ${{ github.event.merge_group.base_sha }} | |
| GROUP_HEAD: ${{ github.event.merge_group.head_sha }} | |
| PUSH_BEFORE: ${{ github.event.before }} | |
| PUSH_AFTER: ${{ github.event.after }} | |
| run: | | |
| set -euo pipefail | |
| case "$EVENT" in | |
| pull_request) range="${PR_BASE}..${PR_HEAD}" ;; | |
| merge_group) range="${GROUP_BASE}..${GROUP_HEAD}" ;; | |
| push) | |
| # A new branch pushes forty zeroes as `before`; there is no range to | |
| # scan, so the whole history is the range. | |
| if [ "$PUSH_BEFORE" = "0000000000000000000000000000000000000000" ]; then | |
| range="" | |
| else | |
| range="${PUSH_BEFORE}..${PUSH_AFTER}" | |
| fi | |
| ;; | |
| *) range="" ;; | |
| esac | |
| if [ -z "$range" ]; then | |
| gitleaks git --config .gitleaks.toml --redact --verbose . | |
| else | |
| gitleaks git --config .gitleaks.toml --redact --verbose --log-opts="$range" . | |
| fi | |
| # #304: the analysis was not a required check. `analyze` lives in codeql.yml, so | |
| # this file's aggregate could not see its conclusion and no required context | |
| # named it — a pull request could add a high result and merge with every | |
| # required check green. This job turns that conclusion into a required verdict. | |
| # | |
| # It reads the analysis this event's commit already has rather than starting | |
| # one, and the commit is chosen per event in scripts/lib/codeql-required.mjs: a | |
| # `merge_group` run reads the queued pull request head, because the synthetic | |
| # merge commit is analyzed by nothing and waiting on it would block every queue | |
| # entry. The wait for a slow analysis is bounded there too (ten minutes, | |
| # twenty-second polls); failing on the first poll would turn a queued runner | |
| # into a red required check. | |
| codeql-required: | |
| name: codeql-required | |
| needs: changes | |
| if: needs.changes.outputs.codeql == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| # A job-level block replaces the workflow set, so both scopes are named. | |
| contents: read | |
| actions: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - name: The analysis for this commit must exist and be green | |
| env: | |
| # A step inherits no token; the lookup needs one, mapped the way | |
| # review-gate.yml maps its own. | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| GITHUB_EVENT_PATH: ${{ github.event_path }} | |
| GITHUB_SHA: ${{ github.sha }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| # No `npm ci`: the script is plain node and reads only the API and the | |
| # event payload, so a dependency install cannot be the thing that fails. | |
| run: node base/scripts/codeql-required.mjs | |
| # #644: a deploy-only diff is not code, so the app gates skip it, but `deploy/` | |
| # ships executable and syntax-sensitive files. `changes` sets `deploy` when any | |
| # deploy/ path is in the diff; this job reads those files instead of letting a | |
| # reviewer be the only check. The classifier cannot call a mixed diff | |
| # deploy-only: `source` is computed from the same list, so deploy/ + src/ sets | |
| # both flags and the code gates still run, and `ci-ok` refuses a skipped | |
| # `deploy-check` when the flag is true. | |
| deploy-check: | |
| name: deploy-check | |
| needs: changes | |
| if: needs.changes.outputs.deploy == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - name: Parse the Cloudflare email worker | |
| run: node --check deploy/cloudflare-email-worker.js | |
| - name: Check the Prometheus alert rules | |
| env: | |
| # The release archive is checksum-pinned in the script, not downloaded | |
| # from a floating `latest`; the checksum is from the release's own | |
| # sha256sums.txt. | |
| PROMTOOL_VERSION: 2.55.1 | |
| PROMTOOL_SHA256: 19700bdd42ec31ee162e4079ebda4cd0a44432df4daa637141bdbea4b1cd8927 | |
| run: | | |
| set -euo pipefail | |
| archive="/tmp/prometheus_${PROMTOOL_VERSION}.tar.gz" | |
| curl -sSfL -o "$archive" \ | |
| "https://github.com/prometheus/prometheus/releases/download/v${PROMTOOL_VERSION}/prometheus-${PROMTOOL_VERSION}.linux-amd64.tar.gz" | |
| echo "${PROMTOOL_SHA256} ${archive}" | sha256sum -c - | |
| tar xzf "$archive" -C /tmp "prometheus-${PROMTOOL_VERSION}.linux-amd64/promtool" | |
| "/tmp/prometheus-${PROMTOOL_VERSION}.linux-amd64/promtool" check rules deploy/prometheus/fledge-alerts.yml | |
| - name: Best-effort nginx and systemd checks | |
| # nginx and systemd-analyze are not guaranteed on the runner. When they | |
| # are absent the gap is written to the job summary; when they are present | |
| # the nginx example is parsed against generated certificates and a | |
| # failure is a failure, while systemd-analyze stays advisory because its | |
| # output includes the runner's own unrelated units. | |
| # | |
| # The wrapper, the generated certificates and the parse itself live in | |
| # scripts/check-nginx-config.sh: it renders deploy/nginx.check.conf, | |
| # which declares a pid path inside its own temporary directory, because | |
| # the compiled-in /run/nginx.pid is not writable by the runner and that | |
| # failed this job on main (#795). test/nginxCheck.test.ts drives that | |
| # script with a stub nginx and inspects the rendered file. | |
| # | |
| # The script's output is teed rather than redirected: a red step has to | |
| # say why in the log, and `$GITHUB_STEP_SUMMARY` is not the log (#799). | |
| shell: bash | |
| run: | | |
| set -uo pipefail | |
| summary="${GITHUB_STEP_SUMMARY:-/dev/null}" | |
| status=0 | |
| if ! bash scripts/check-nginx-config.sh 2>&1 | tee -a "$summary"; then | |
| status=1 | |
| fi | |
| if command -v systemd-analyze >/dev/null 2>&1; then | |
| if systemd-analyze verify deploy/fledge.service > /tmp/systemd-verify.txt 2>&1; then | |
| echo 'systemd: deploy/fledge.service parsed' >> "$summary" | |
| else | |
| echo 'systemd: `systemd-analyze verify` reported problems (advisory, not a gate):' >> "$summary" | |
| sed -n '1,20p' /tmp/systemd-verify.txt >> "$summary" | |
| fi | |
| else | |
| echo 'systemd: systemd-analyze is not on this runner — deploy/fledge.service was not verified (gap).' >> "$summary" | |
| fi | |
| exit "$status" | |
| ci-ok: | |
| name: ci-ok | |
| if: always() | |
| needs: [changes, lint, typecheck, test, coverage, build, visual, docker, audit, dependency-review, changelog, version, risk, restore-drill, action-pins, workflow-lint, secret-scan, codeql-required, deploy-check] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| # #640: the gate is policy, so its code comes from the base branch and | |
| # never from the pull request it judges. `path: base` keeps it beside | |
| # the checkout that supplies the data under test. | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.repository.default_branch }} | |
| path: base | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| # #640: a reviewer has to be able to see in the log which copy of the gate | |
| # judged the pull request. This names the base tree's blob for the | |
| # evaluator the next step runs, so a tampered copy on the branch cannot be | |
| # mistaken for it — the sha printed here is the one the verdict came from. | |
| - name: Name the evaluator that comes from the base tree | |
| run: | | |
| set -euo pipefail | |
| echo "scripts/ci-ok.mjs from the base tree: blob $(git -C base rev-parse HEAD:scripts/ci-ok.mjs)" | |
| # `skipped` is a failure here unless the job is named in SKIPPABLE_ON for | |
| # this event, and a required job missing from `needs` fails too. | |
| - name: Every required job passed | |
| env: | |
| NEEDS: ${{ toJSON(needs) }} | |
| CHANGES: ${{ toJSON(needs.changes.outputs) }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| run: node base/scripts/ci-ok.mjs | |
| # #305: a red `main` used to be invisible. `ci-ok` computed the commit's | |
| # health and stopped, no job held a write scope on issues, and the branch was | |
| # noticed only by a person who went and looked. This is the one job allowed to write, | |
| # and it writes one issue: create it, comment on it, or close it. Every | |
| # decision is `scripts/lib/main-health.mjs`; the job only gathers the input. | |
| # | |
| # It is deliberately not in `ci-ok`'s `needs`: a reporter that fails must not | |
| # be able to fail the merge gate as well. | |
| # | |
| # Its `needs` mirrors `ci-ok`'s so the tracker can name the jobs that actually | |
| # failed. With only `ci-ok` in the list the reporter saw the aggregate and | |
| # nothing else, and every tracker said "ci-ok did not report success". | |
| main-health: | |
| name: main-health | |
| if: always() && github.ref == 'refs/heads/main' | |
| needs: [ci-ok, changes, lint, typecheck, test, coverage, build, visual, docker, audit, dependency-review, changelog, version, risk, restore-drill, action-pins, workflow-lint, secret-scan, codeql-required, deploy-check] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| issues: write | |
| # One red state, one reporter. `cancel-in-progress: false` is deliberate: a | |
| # second push must not cancel the run between its read and its create. | |
| concurrency: | |
| group: ci-main-red | |
| cancel-in-progress: false | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 22 | |
| - name: Report the default branch's health | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| NEEDS: ${{ toJSON(needs) }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| GITHUB_REF: ${{ github.ref }} | |
| GITHUB_SHA: ${{ github.sha }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| GITHUB_SERVER_URL: ${{ github.server_url }} | |
| GITHUB_RUN_ID: ${{ github.run_id }} | |
| run: node scripts/main-health.mjs |