Skip to content

feat(security): classify every mutating route and pace the worst ones #1334

feat(security): classify every mutating route and pace the worst ones

feat(security): classify every mutating route and pace the worst ones #1334

Workflow file for this run

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