This is the canonical definition of the merge-blocking pipeline and the GitHub Actions security posture every repo in this portfolio ships. It owns the shape of CI (stage order, gates, token model, branch protection, deploy/release safety); it does not own the content of individual gates — those live in their own standards and are referenced here, not repeated:
| This doc references | For |
|---|---|
SECURITY-AND-SUPPLY-CHAIN-STANDARD.md |
SHA pinning, SBOM, cosign/SLSA, Scorecard thresholds, SAST/SCA/secret/container scanning |
CODE-QUALITY-STANDARD.md |
ruff/mypy/pytest floors, coverage thresholds, uv.lock, src layout |
QUALITY-AND-METRICS-STANDARD.md |
the quality-attribute taxonomy and the per-repo Metrics ledger shape |
ACCESSIBILITY-STANDARD.md |
axe/Lighthouse/pa11y gates, target-size, ACR |
AI-EVALUATION-STANDARD.md |
faithfulness/red-team/calibration gates on prompt/retrieval PRs |
OBSERVABILITY-STANDARD.md |
structured-log schema, OTel, /livez//readyz |
INTERNATIONALIZATION-STANDARD.md |
catalog format, key-parity, pseudolocale |
Enforcement is binary. A control is AUTO-GATE (mechanically checkable, merge-blocking in CI) or REVIEW-GATE (human judgment, paired with a checklist item and a committed artifact). There is no "aspirational" third category. If a row below cannot be made one of these two, it is a defect in this document.
The threat model is active, not theoretical: the March 2026 trivy-action force-push (secrets exfiltrated from 75 tags), the tj-actions compromise, and the February 2026 AI-automated cache-poisoning campaign against Microsoft/DataDog/CNCF repos are why every control below exists. ~13 of 19 repos in this portfolio still reference actions by mutable tag; this standard closes that.
Every repo ships one CI workflow (.github/workflows/ci.yml) whose jobs run these stages in this order. A merge to main requires every applicable stage green. make verify (Python) / npm run verify (TS) runs the same gates locally — see §9.
1. format ruff format --check / prettier --check → fail on any deviation
2. lint ruff check (+ zizmor on workflow PRs) → fail on any finding
3. type mypy --strict / tsc --noEmit → zero errors
4. test pytest --cov (branch>=85%, libs>=90%) / vitest → fail under threshold
5. security semgrep + gitleaks + pip-audit/osv + trivy → fail HIGH/CRITICAL (see SEC std)
6. a11y axe-core + pa11y-ci + Lighthouse (UI repos) → see ACCESSIBILITY std
7. perf k6 / Lighthouse CI budgets → regression >10% fails
8. responsible eval/citation/consent/no-outing gates → see RESPONSIBLE-TECH + AI-EVAL
9. build build artifact + container + SBOM + provenance → see SECURITY std
Stages 1–5 are mandatory for every repo (including self-osint-monitor, which must scaffold this as its M0 deliverable before any M1 code, and gtfs-scorecard, whose root has no CI today). Stages 6–8 apply by repo shape and are declared applicable or N/A-with-reason in the repo's ROADMAP.md Metrics ledger — silently skipping a stage is a defect.
| Stage | Applies to | N/A-with-reason permitted when |
|---|---|---|
| 1–5 | all repos | never |
| 6 a11y | any repo emitting HTML/UI (frontends, eval harnesses whose reports are user-facing) | no human-facing HTML output, declared in ledger |
| 7 perf | hosted services, frontends, LLM routes | pure library/CLI with no latency contract |
| 8 responsible | AI/RAG/eval, privacy-first, civic repos | repo's Responsible-Tech audit marks the gate N/A |
Default is write; this is wrong. Org-level default is set to read-only (Settings → Actions → General → "Read repository contents and packages permissions"), and every workflow declares a top-level permissions block. Write is granted per-job, never top-level.
| Metric | Target | Measured by | Gate |
|---|---|---|---|
Top-level permissions: present |
every workflow | zizmor + Scorecard Token-Permissions | AUTO-GATE |
| Token-Permissions score | 10/10 | ossf/scorecard-action on default branch |
AUTO-GATE (fail < 8) |
| Write scopes | job-level only, minimal set | zizmor excessive-permissions rule |
AUTO-GATE |
Currently missing the block entirely: gtfs-scorecard, jobradar, personal-site, tods-validate ci.yml. Required pattern:
# top of every workflow — deny by default
permissions:
contents: read
jobs:
verify:
permissions:
contents: read # explicit even when same as top-level
# ...
publish:
permissions:
contents: read
id-token: write # OIDC, see §3
attestations: write # SLSA provenance, see SECURITY std
packages: write # only this job, only because it pushesNo secrets: inherit in reusable-workflow calls — pass each secret explicitly. persist-credentials: false on every actions/checkout. No untrusted github.* context interpolated into run: blocks — assign to an intermediate env: var first (zizmor template-injection, merge-blocking).
Every workflow that touches AWS/GCP/Azure authenticates via OIDC; long-lived cloud keys in Actions secrets are prohibited. OIDC is already adopted where AWS is used (jobradar, fare-assistant, personal-site, civic-ai-eval-harness publishing) — this makes it mandatory and closes the trust scope.
| Metric | Target | Measured by | Gate |
|---|---|---|---|
| Cloud creds via OIDC | 100% of cloud-touching jobs | grep for aws-access-key/static creds in workflows; zizmor |
AUTO-GATE |
| OIDC trust subject scope | repo:org/repo:environment:<env> (never :*, never org-wide) |
review of cloud trust policy artifact | REVIEW-GATE |
| New long-lived cloud secret added | alert | org audit-log rule on org.update_actions_secret |
REVIEW-GATE |
deploy:
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<40-char-sha> # v4.x — see SEC std for pinning
with:
role-to-assume: arn:aws:iam::ACCT:role/personal-site-deploy
aws-region: us-west-2
# NO aws-access-key-id / aws-secret-access-key anywhereThe IAM trust policy sub must pin to the specific repo + environment claim, e.g. repo:ckellyreif/personal-site:environment:production. A wildcard subject is a REVIEW-GATE failure.
The full pinning/SBOM/signing posture lives in SECURITY-AND-SUPPLY-CHAIN-STANDARD.md. CI restates only the merge-blocking surface that this pipeline enforces:
| Metric | Target | Measured by | Gate |
|---|---|---|---|
Every uses: pinned to 40-char SHA + # vX.Y.Z comment |
100% (incl. reusable workflows and the deploy path) | zizmor unpinned-uses + Scorecard Pinned-Dependencies |
AUTO-GATE |
| Pinned-Dependencies score | ≥ 9/10 | ossf/scorecard-action |
AUTO-GATE |
| SHA freshness | Renovate helpers:pinGitHubActionDigestsToSemver, minimumReleaseAge: 72h |
committed renovate.json / dependabot.yml |
AUTO-GATE (config present) |
Today only habitable, trans-docs-navigator, civic-ai-eval-harness/govchat-eval pin. nearmiss (0/17), gtfs-scorecard (0/2) are fully tag-pinned; trans-docs-navigator leaks one straggler tag in deploy-aws-preview.yml — the deploy path is in scope, fix it. Migrate with pin-github-action or StepSecurity Action-Advisor:
# pins every uses: to its current SHA + version comment, repo-wide
npx pin-github-action .github/workflows/*.yml
# verify nothing references a tag/branch
! grep -rEn 'uses:.*@(v?[0-9]|main|master|latest)\b' .github/workflows/Branch protection is enforced via org-level GitHub Rulesets (not per-repo settings, which admins can edit) and committed as a per-repo artifact so the posture is reviewable in-tree. This is essentially absent portfolio-wide today and must be added everywhere.
| Metric | Target | Measured by | Gate |
|---|---|---|---|
| Branch-Protection score | ≥ 8/10 | ossf/scorecard-action |
AUTO-GATE |
Required reviewers on main/release/* |
≥ 1 (≥ 2 for civic/PII repos: fare-assistant, gtfs-scorecard, civic-rag) |
ruleset artifact .github/rulesets/main.json |
REVIEW-GATE |
| Required status checks | format,lint,type,test,security + zizmor + codeql-actions + applicable a11y/perf/responsible |
ruleset required_status_checks |
AUTO-GATE |
| Dismiss stale reviews on push | on | ruleset | AUTO-GATE |
Admin bypass on main |
disabled (enforce_admins: true) |
direct-push test (owner push must fail) | AUTO-GATE |
Force-push to main/release/* |
blocked | ruleset | AUTO-GATE |
The committed ruleset doubles as evidence and feeds SLSA Source Track L2 (attest-build-provenance populates sourceLevels only when branch protection with required reviews is active — see SECURITY std).
A committed CODEOWNERS routes .github/workflows/, rulesets, IaC, and security-critical files to a required reviewer; the ruleset sets require_code_owner_review. This is mechanically enforced — a PR touching a workflow cannot merge without that review. Absent portfolio-wide today.
# .github/CODEOWNERS
* @ckellyreif
/.github/workflows/ @ckellyreif # workflow edits = poisoned-pipeline risk
/.github/rulesets/ @ckellyreif
/infra/ /terraform/ @ckellyreif
/SECURITY.md /.github/dependabot.yml /renovate.json @ckellyreif
# repo-specific responsible-tech guards (no-outing, consent gate, citation):
/tests/test_no_outing.py @ckellyreif # ledger
/tests/test_no_identity_inference.py @ckellyreif # women-artist-discovery
| Metric | Target | Gate |
|---|---|---|
CODEOWNERS exists and covers .github/workflows/ |
yes | AUTO-GATE (file presence + path coverage check) |
| Code-owner review required by ruleset | on | REVIEW-GATE (ruleset artifact) |
The workflows themselves are scanned. zizmor nowhere in the portfolio today — required.
| Metric | Target | Measured by | Gate |
|---|---|---|---|
zizmor on any PR touching .github/workflows/** |
zero High/Critical findings; SARIF to Code Scanning | required status check zizmor |
AUTO-GATE |
CodeQL language: actions |
nightly + on workflow PRs | required status check codeql-actions |
AUTO-GATE |
| Dangerous-Workflow score | 10/10 | Scorecard (no pull_request_target checkout of untrusted code; no script injection) |
AUTO-GATE (fail < 10) |
# .github/workflows/zizmor.yml
name: zizmor
on:
pull_request: { paths: ['.github/workflows/**', '.github/actions/**'] }
permissions:
contents: read
security-events: write # SARIF upload only
jobs:
zizmor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha> # v4.x; persist-credentials: false
with: { persist-credentials: false }
- uses: zizmorcore/zizmor-action@<sha> # vX.Y.Zpull_request_target / workflow_run are prohibited unless the workflow verifies actor membership and never checks out PR code; the only approved cross-privilege pattern is unprivileged pull_request uploads artifact → privileged workflow_run verifies then acts (zizmor + Scorecard enforce this).
Production/staging deploys run only through a named GitHub Environment with ≥ 1 required reviewer and "Protected branches only" deployment restriction. Even a workflow running a compromised action cannot reach prod without human sign-off.
| Metric | Target | Gate |
|---|---|---|
| Prod/staging deploy gated by named Environment + required reviewer + prevent-self-review | yes | REVIEW-GATE (committed environments-audit-YYYY-QN.json, quarterly) |
Deploy/release workflows set a concurrency group to prevent racing deploys; called reusable workflows declare their own group independent of the caller. Inconsistently applied today (gtfs-scorecard has 10 workflows, eval harnesses 9, most repos 1–2).
concurrency:
group: deploy-${{ github.ref }} # deploy/release: serialize
cancel-in-progress: false # never cancel an in-flight deploy
# CI/lint jobs may use cancel-in-progress: true| Metric | Target | Measured by | Gate |
|---|---|---|---|
concurrency: on every deploy/release job |
present, cancel-in-progress: false |
workflow-lint check / zizmor | AUTO-GATE |
Cache (actions/cache, cache: true on setup-*) is prohibited in any job that deploys to prod, publishes a package, generates SLSA provenance, or holds id-token: write — cache poisoning violates SLSA L3 isolation. Cache is allowed only in pull_request-triggered build/test jobs.
| Metric | Target | Measured by | Gate |
|---|---|---|---|
No caching in release/publish/provenance/id-token jobs |
enforced | zizmor cache-poisoning rule + workflow-lint | AUTO-GATE |
Reusable org workflows. Build/test/security-scan/deploy logic lives once in a shared .github repo (ckellyreif/.github) and product repos call it rather than copy-pasting. Callers must not override security-critical inputs (permissions, environment names). This kills the per-repo drift that lets a green build in one repo fail in another.
jobs:
ci:
uses: ckellyreif/.github/.github/workflows/python-verify.yml@<sha> # vX.Y.Z
permissions:
contents: read
# caller may pass project values (coverage floor) but NOT permissions/secrets blanket
with: { cov-fail-under: 90 } # libraries; default 85| Metric | Target | Gate |
|---|---|---|
| Build/deploy logic via central reusable workflow | yes (product repos) | REVIEW-GATE (quarterly reusable-workflow security review, committed) |
Caller does not override permissions/secrets |
enforced | AUTO-GATE (zizmor on caller) |
make verify ≡ CI. The single command runs the same gate set CI runs, eliminating local/remote drift. Several Python repos already ship a make verify that is byte-for-byte identical to CI — this makes that the portfolio rule. CI calls the same Makefile target it asks contributors to run; the two cannot diverge.
# Makefile — CI invokes `make verify`; nothing in CI runs gates the Makefile doesn't
verify: format-check lint type test security
format-check: ; ruff format --check .
lint: ; ruff check .
type: ; mypy --strict src
test: ; pytest --cov=src --cov-branch --cov-fail-under=85
security: ; pip-audit && gitleaks detect --no-banner # NO `|| true` — see SECURITY std| Metric | Target | Measured by | Gate |
|---|---|---|---|
make verify runs the exact CI gate set |
identical command surface | CI job that only runs make verify for stages 1–5 |
AUTO-GATE |
Config consolidated in single pyproject.toml, uv sync --frozen |
no ruff.toml/pytest.ini/requirements.txt strays |
CODE-QUALITY-STANDARD | AUTO-GATE |
Repo-specific layout fixes this standard surfaces (resolved under CODE-QUALITY-STANDARD, gated here via "make verify at repo root must exist and pass"):
jobradar: nopyproject.toml— splits config acrossrequirements.txt+ruff.toml+pytest.ini, no mypy. Consolidate; add type gate.gtfs-scorecard: real project hidden atpipeline/pyproject.toml, no rootmake verify/uv.lock. Add rootMakefiledelegating topipeline/, or hoist config.self-osint-monitor: spec-only, no CI/Makefile/pyproject. Scaffold §1 + §2 + §9 as the M0 deliverable before M1 code (and sequence the consent gate per AI-EVAL/RESPONSIBLE-TECH).queer-the-stacks/queer-specfic-reader: duplicatequeer_the_stackspackage. Reconcile to one repo before applying this standard twice (REVIEW-GATE: documented fork/rename decision).
Each repo's ROADMAP.md Metrics ledger (shape defined in QUALITY-AND-METRICS-STANDARD.md) carries the CI-specific rows so enforcement is unambiguous and every optional stage is explicitly applicable or N/A-with-reason:
| Metric | Target | Measured by | Gate | Owner |
|---|---|---|---|---|
| Token-Permissions | 10/10 | scorecard-action | AUTO-GATE | — |
| Pinned-Dependencies | ≥ 9/10 | scorecard-action | AUTO-GATE | — |
| Branch-Protection | ≥ 8/10 | scorecard-action | AUTO-GATE | — |
| Dangerous-Workflow | 10/10 | scorecard-action | AUTO-GATE | — |
| Cloud auth via OIDC | 100% | workflow grep + zizmor | AUTO-GATE | — |
| zizmor on workflow PRs | 0 High/Crit | required check | AUTO-GATE | — |
make verify ≡ CI |
identical | CI job | AUTO-GATE | — |
| Deploy reviewer gate | env + ≥1 reviewer | environments-audit | REVIEW-GATE | — |
| a11y / perf / responsible stages | applicable or N/A-with-reason | ledger row | per stage | — |
A stage marked N/A must carry a one-line reason (e.g. "perf: pure library, no latency contract"; "i18n: single-user English-only CLI, entry point _() documented"). A blank or missing row is a merge-blocking defect.
Last verified: 2026-06-21 · Recheck cadence: per GitHub Actions security-feature release, OpenSSF Scorecard minor (currently v5.5), SLSA spec revision (currently v1.2), and OWASP Top-10 CI/CD update — review at least quarterly given the active supply-chain threat environment. Confirm current action SHAs, Scorecard check weights, and GitHub ruleset schema at build time.