CI architecture¶
Every PR runs a required set of jobs that gate auto-merge; most of them re-run on push to main, though a few are PR-only because they have nothing to inspect on a push (dependency-review reads the PR diff, lint-pr-title reads the PR title). Separate non-blocking workflows run informational checks — weekly image CVE scans, a monthly docs-audit reminder, and PR-time SAST among them; the full scheduled set is in Cron schedule.
Build and smoke gates¶
The seven functional gates below are the build-and-run core of the required set; the full list of 27 required contexts is in the next section.
flowchart LR
pr["PR / push to main"]
flakecheck["flake-check<br/>(ubuntu-latest)"]
build["build-linpeas<br/>(ubuntu-latest)"]
smoke["smoke-test"]
buildarm["build-linpeas-arm64"]
smokearm["smoke-test-arm64"]
image["image-smoke<br/>docker run -h"]
imagearm["image-smoke-arm64"]
merge{"all green?"}
ok["auto-merge"]
block["block merge"]
pr --> flakecheck
pr --> build --> smoke
pr --> buildarm --> smokearm
pr --> image
pr --> imagearm
flakecheck --> merge
build --> merge
buildarm --> merge
smoke --> merge
smokearm --> merge
image --> merge
imagearm --> merge
merge -- yes --> ok
merge -- no --> block
Required check list¶
The canonical list — mirroring the protect-main branch ruleset — lives
in docs/security/required-checks.md.
The tables below group all 27 required
contexts by what they gate; consult
the canonical doc as source of truth. That grouping is this page's own. The
README's Continuous integration summary files the same contexts under the
categories in docs/_data/ci-check-categories.yml, which also colours the
CI DAG; nothing compares that partition with this page's.
Functional gates:
| Job | Runner | What it tests |
|---|---|---|
flake-check |
ubuntu-latest |
nix flake check — eval, the pre-commit hook set (treefmt, deadnix, statix, actionlint, yamllint, shellcheck, …), and the flake checks (checks.formatting, checks.lint-shell-tools, checks.devshell-renderer-python, and the pinned derivation build checks.linpeas-build); plus check-flake-systems-eval.sh (every declared system's packages force-evaluate) and its guard harness |
build-linpeas |
ubuntu-latest |
nix build .#linpeas — fetches upstream linpeas.sh, verifies SRI hash, builds the derivation |
smoke-test |
ubuntu-latest |
./result/bin/linpeas -h exits 0 |
build-linpeas-arm64 |
ubuntu-24.04-arm |
aarch64 build of linpeas |
smoke-test-arm64 |
ubuntu-24.04-arm |
aarch64 -h smoke |
image-smoke |
ubuntu-latest |
builds OCI image, docker load, docker run --rm <img> -h exits 0; asserts grep sed awk find ps are present in the image; runs -o system_information against a separate target container and fails on any : command not found line |
image-smoke-arm64 |
ubuntu-24.04-arm |
aarch64 run of the same three image-smoke steps |
Self-enforcing invariant gates: the three lint-* rows in the table below
are batched group jobs (lint-pr-title, further down, is not); their member lints are enumerated in
.github/lint-groups.yml, which is the source of truth for what each
group runs. Each of these three group jobs runs each member's
tests/check-<member>.test.sh fixture harness, where one exists
(jsonschema has none, by exemption), before that member's enforcer
and fails on either, so a harness regression reddens the required
context even with a clean tree:
| Job | What it enforces |
|---|---|
dashboard-data-tests |
scripts/gen-dashboard-data.sh security guards (pin shape, asset-URL prefix, missing-field hard-fail) |
pr-workflows-no-secrets |
PR-triggered workflows reference no secrets.* other than secrets.GITHUB_TOKEN |
renovate-invariants |
renovate.json keeps SHA-digest pinning, minimumReleaseAge, no top-level automerge, and pinDigests: true for github-actions; every # renovate: marker's file is still consumed by a live customManager (scripts/check-renovate-markers-matched.sh) |
required-checks-no-paths |
No required workflow declares paths: / paths-ignore: under pull_request: |
lint-workflow-security |
Batched workflow-security member lints; e.g. member check uses-sha-pinned: every uses: in workflows + composite actions is a full 40-hex SHA (or a ./... self-ref). The trailing # vX.Y[.Z] patch-tag comment is a separate rule, patch-tag-pins, enforced as a pre-commit hook only — it is in no lint group, and harness-group runs only its fixture tests, so no required check probes the live tree |
cliff-tag-pattern |
cliff.toml's tag_pattern still matches the canonical pin-shape regex, so git-cliff keeps seeing the release tags |
harness-group |
Every test harness in the group runs, and ratchet-pin-audit additionally runs its enforce script against the live checkout — the only roster entry that does; the rest run no enforce script, though some harnesses still assert live-tree facts — for example the actionlint canaries check that the hook's file filter watches the pin, and the staleness harness's live scenario checks its own check can still read flake.lock (pinned off that lock's newest lastModified, so no age is asserted) |
lint-doc-invariants |
Batched doc-invariant member lints; e.g. invariant-index integrity, doc anchors, ephemeral references, cron restatement, and schema validation |
lint-script-hygiene |
Batched shell-hygiene member lints over scripts/, tests/, and the tooling config those scripts and hooks are declared in; e.g. shebang/pipefail, harness preambles, per-script test coverage, helper routing, and path hygiene |
setup-nix-required |
Every workflow installing Nix goes through the ./.github/actions/setup-nix composite with github-token, never a vendor installer directly |
Doc-quality + conventional-commit gates (all alphabetical):
| Job | What it enforces |
|---|---|
changelog-links |
The regenerated changelog carries no duplicate PR links and keeps the scorecard-count preprocessor; released sections match a fresh git-cliff regeneration. |
commitlint |
Every branch commit independently satisfies Conventional Commits. |
doc-freshness |
Every refresh-*.sh generator still reproduces its own output; refresh-flake-show, refresh-pin-parity and refresh-ephemeral-refs-gap also --check the committed tree, while the rest regenerate first, so the committed-file gate for those is their *-fresh pre-commit hook (see linting.md; the changelog's freshness is changelog-links's). |
editorconfig |
.editorconfig compliance (charset, line endings, trailing whitespace, final newline). |
lint-pr-title (workflow pr-title-lint) |
PR title independently satisfies Conventional Commits and is spell-checked with typos. The PR title becomes the merge-commit subject with " (#<number>)" appended, and the lint checks that composed string. |
markdownlint |
Markdown style + structure. |
typos |
Spell-check across the repo. |
Supply-chain + secret-scanning gates:
| Job | What it enforces |
|---|---|
dependency-review |
Reviews the pull request's dependency changes for known vulnerabilities. |
gitleaks |
Secret scan. |
trufflehog |
Secret scan with a complementary detector set. |
Merge policy¶
Merge-commit only. Enforced at both layers:
- Repo:
allow_merge_commit=true,allow_rebase_merge=false,allow_squash_merge=false. - Ruleset:
pull_request.allowed_merge_methods=["merge"].
required_signatures is enforced on the protect-main ruleset. Every commit on main (branch commit + merge commit) must carry a valid signature. Branch commits sign locally; bot commits originate from REST PUT /contents authenticated as the linpeas-flake-bumper GitHub App and are web-flow-signed by GitHub. See Repository configuration for the full posture.
Non-blocking coverage / advisory checks¶
image-cve-scan.yml(weekly cron + push on the paths that change the image + dispatch) runs Trivy and Grype against the released OCI image and uploads SARIF to code-scanning under distinct categories (trivy-image-cve,grype-image-cve) for cross-scanner DB coverage.- Most findings are CVE-DB-driven, not PR-driven, so the scheduled run against a fresh DB is the meaningful signal — and it fires even in weeks with no PR activity. The push trigger covers the other direction: when the image itself changes, waiting for the next Friday leaves the new contents unscanned for up to a week, and no cron ordering helps a bump that lands off-schedule.
- Both scanners are advisory only (job-level failure is
count > 0of CRITICAL CVEs or an infrastructure failure ahead of the count; a CRITICAL finding does not stop the SARIF upload); each scanner files deduped issues under separate finding and infrastructure labels, on the results listed in the OCI image CVE scan sections; the prevention path is a nixpkgs bump viaupdate-flake-lock.
docs-audit-reminder.yml(monthly cron + dispatch) opens a dedupeddocs-auditissue when CI-structure churn suggests hand-written CI prose has drifted. Advisory by construction: freshness gates cover only generated content (whole generator-owned files and spliced blocks), and the semantic claims they miss need a reading agent rather than a lint. Non-zero drift pressure never fails the job — a workflow that reddens during normal CI churn trains the maintainer to ignore red — but a genuine failure, such as the pressure script erroring or a GitHub API call failing, does go red, because that is a real problem rather than a drift signal. Drift pressure counts commits touching CI structure since the commit recorded in.github/docs-audit-state, so it reads as change nobody has audited yet: the issue closes itself once that count is zero, which is what runningjust docs-audit-donein an audit's final fix PR produces. An audit whose point was never recorded leaves pressure climbing and the issue open, which is the correct signal for an audit that never happened. See Docs correctness audit for the full loop.octoscan.yml's PR trigger is paths-filtered to the files it reads (.github/workflows/**plusscripts/octoscan-scan.sh; composite actions under.github/actions/are not scan targets).codeql.ymldeliberately runs on every PR with no paths filter, so the OpenSSF Scorecard SAST check keeps seeing a SAST run on every merged PR (it scores the fraction analysed, and a paths filter would drag that fraction down). Both stay outside the required set: octoscan becauserequired-checks-no-pathsforbids paths filters on required workflows, codeql so that a single CRITICAL false positive or a CodeQL infrastructure flake cannot wedge every PR.
The full layered model — which scanner runs when, and why the overlap is a budgeted defense-in-depth posture rather than redundancy — is documented in workflow-scanner division of labor.
Runner egress¶
Every job's first step is step-security/harden-runner with egress-policy: block and a per-job allowed-endpoints: allowlist. The eBPF monitor enforces the allowlist and must remain the first step in every job. A missed host appears as a blocked-egress failure and is fixed forward by extending that job's allowlist.
Pages workflow¶
The Pages workflow (pages.yml) is not in the required set. Its build job runs on every PR for visibility; on non-PR events a build or deploy failure auto-files a deduped issue tagged pages-build-failure. The notify job is skipped on pull_request, so a failure on a contributor branch files nothing. Coupling the Pages build to merge-gating would invert the priority — the supply-chain pipeline is higher priority than the documentation site.
flowchart TD
trigger["pages.yml<br/>push to main /<br/>PR / release / cron / dispatch"]
data["./scripts/gen-dashboard-data.sh"]
build["nix build 'path:$(pwd)#site'<br/>(path: ref — git ref hides the<br/>gitignored dashboard.yml)"]
smoke["smoke: index.html + dashboard/index.html exist<br/>+ no raw {{ }} in dashboard/index.html"]
isPR{"event == pull_request?"}
deploy["actions/deploy-pages<br/>OIDC, github-pages env"]
pr_only["build only"]
notifygate{"event == pull_request?"}
fail["create / comment<br/>deduped issue"]
quiet["no issue filed"]
trigger --> data --> build --> smoke --> isPR
isPR -- yes --> pr_only
isPR -- no --> deploy
build -. failure .-> notifygate
smoke -. failure .-> notifygate
deploy -. failure .-> notifygate
notifygate -- yes --> quiet
notifygate -- no --> fail
Cache¶
Nix-based jobs pull from the public cache.nixos.org substituter; there is no repo-specific Nix binary cache layered on top.
Action pins¶
All third-party actions are SHA-pinned, with # vX.Y[.Z] patch-tag comments except for refs carrying an inline # patch-tag-exception: marker — see pin convention. Renovate maintains them via helpers:pinGitHubActionDigests + explicit pinDigests: true in renovate.json.
Cron schedule¶
Every fixed-time schedule fits the maintainer's monitoring windows: daily crons run 08:00–10:00 UTC, weekly crons run Friday 05:00–07:00 UTC (both year-round inside the intended US-Eastern early-morning windows regardless of DST). One schedule runs outside those windows: ci-watchdog is a backstop that must fire around the clock, not a monitoring-window report, so it runs on a continuous 30-minute cadence instead. The monthly docs-audit-reminder sits at the very tail of the daily window (10:00 on the 1st) and clear of every daily cron in it, so a once-a-month reminder never competes with the bump pipeline for attention.
| Workflow | Cron | UTC | Purpose |
|---|---|---|---|
ci-watchdog |
20,50 * * * * |
every 30 min | Re-run failed jobs on stuck auto-merge bot PRs, bounded to 3 attempts |
actions-cache-prune |
0 8 * * * |
08:00 daily | Bound the lifetime of any Actions-cache entry a third-party action creates (nothing in-repo writes one) |
update-linpeas |
5 8 * * * |
08:05 daily | Check upstream peass-ng for new release; open auto-merge bump PR |
stale-pin-check |
0 9 * * * |
09:00 daily | Auto-file issue when the pin has sat behind upstream past the staleness threshold |
ratchet-pin-audit |
15 9 * * * |
09:15 daily | Detect an upstream force-move of a pinned action's tag; scope and exclusions in the runbook |
flake-lock-staleness-check |
45 9 * * * |
09:45 daily | Assert every top-level flake input is still being refreshed |
pages |
55 9 * * * |
09:55 daily | Rebuild dashboard from current pin + upstream + release JSON |
update-flake-lock |
0 5 * * 5 |
Fri 05:00 | Refresh flake.lock via auto-merge PR |
reproducibility-check |
10 5 * * 5 |
Fri 05:10 | Rebuild flake outputs twice; fail on hash divergence |
image-cve-scan |
30 5 * * 5 |
Fri 05:30 | Trivy + Grype CVE scan of the OCI image; SARIF to code-scanning |
verify-latest-release |
40 5 * * 5 |
Fri 05:40 | Re-fetch published artifacts; verify SRI hash + attestations |
links |
50 5 * * 5 |
Fri 05:50 | Markdown link checker (lychee); cron + dispatch only, not a required check |
codeql |
0 6 * * 5 |
Fri 06:00 | CodeQL static analysis (Actions) |
octoscan |
10 6 * * 5 |
Fri 06:10 | Octoscan SAST on .github/workflows/ |
scorecard-drift-check |
20 6 * * 5 |
Fri 06:20 | Run OSSF Scorecard over the curated check set; fail when any included check scores below 10 (the results that page are listed under scorecard) |
zizmor-drift-check |
30 6 * * 5 |
Fri 06:30 | Re-run zizmor --min-severity=low over .github/workflows/; fail on any finding (the results that page are listed under zizmor) |
gitleaks |
40 6 * * 5 |
Fri 06:40 | Full-history secret scan |
trufflehog |
50 6 * * 5 |
Fri 06:50 | Full-history secret scan (complementary detector set) |
docs-audit-reminder |
0 10 1 * * |
1st 10:00 | Monthly prompt to run the semantic docs audit; opens a deduped docs-audit issue when CI churn since the last recorded audit suggests prose has drifted |
Daily crons fire in this UTC order: ci-watchdog (00:20) → actions-cache-prune (08:00) → update-linpeas (08:05) → stale-pin-check (09:00) → ratchet-pin-audit (09:15) → flake-lock-staleness-check (09:45) → pages (09:55). ci-watchdog's minute field (20,50) recurs every 30 minutes around the clock — the 00:20 timestamp is only where its cron string sorts among the fixed-time daily crons, not a single daily firing. Bump-related crons (update-linpeas, stale-pin-check) front-load the window so the dashboard cron at 09:55 reads a settled state; drift checks cluster between them. Weekly crons fire Friday in slot order: update-flake-lock leads at 05:00 so its auto-merge PR's CI runs inside the window, the build- and release-verification chain (reproducibility-check → image-cve-scan → verify-latest-release → links) fills out the first hour, the scanner cluster (codeql → octoscan → scorecard-drift-check → zizmor-drift-check) fills the second, and the secret-scan pair (gitleaks, trufflehog) closes it.
This table is the single source for every schedule: other docs link to it and never restate a literal time or a numeric cadence — check-doc-cron-restatement.sh enforces the restatement shape that drifts, a line under README.md or docs/** that names a workflow and carries a clock time or a numeric cadence (this page and the README's generated ci-summary block excepted); linking back to this table is convention, not lint. The table is checked against the workflows' cron: lines by scripts/check-cron-table.sh — set parity and cron-string accuracy. The daily-order paragraph following the table is diffed against the table's own daily rows, and its times must strictly increase. The script is wired as the check-cron-table pre-commit hook. The cron-table-drift-check workflow runs the same script on pushes to main touching the workflow files, this doc, or the check script (plus manual dispatch).
Pages staleness window¶
On bump days, the 08:05 update-linpeas run opens a PR; required checks plus auto-merge typically complete within an hour, after which release-on-bump.yml cuts the GitHub release. The 09:55 pages cron then reads the freshly-bumped linpeas-pin.json from main plus the just-published release JSON and renders a consistent dashboard. The ~1h50m slack between bump start and dashboard render is an accepted tradeoff for keeping every daily cron inside the maintainer's monitoring window — the typical bump pipeline finishes well inside it.
If the bump pipeline is delayed past 09:55 (rare — CI queue surge, Renovate auto-merge held by a required check), the daily cron reads the previous day's pin and publishes a dashboard claiming drift.days = 1. This is by-design tolerable:
pages.ymlalso runs onpush: branches: [main]andrelease: published, so the dashboard is re-rendered within minutes of any bump merge.- The dashboard page and the trust model self-describe as documentation, not a trust anchor. Authoritative signal lives in
gh attestation verifyagainst the published artifacts, not the dashboard text.
Surfacing "open bump PR" state on the dashboard is deliberately not implemented — it would couple a documentation surface to PR metadata without changing the underlying trust model.
Stale-pin failure attribution¶
stale-pin-check.yml's notify body distinguishes:
reason=pin-malformed— locallinpeas-pin.jsonhas a malformed.versionfield.reason=upstream-api-failure—gh api .../releases/latestfailed or returned empty.reason=upstream-tag-malformed— the API succeeded but upstream published a tag outside the canonical shape.reason=stall-detected— the API call succeeded, the pin is older than the staleness threshold, and upstream's latest release is a different tag.unknown— thecheckjob produced noreasonoutput (an unhandled error, an earlier step failing, or a cancelled run); the body renders it as the default rather than emitting it.
Do not collapse into single failure classification.
Cancelled-job attribution¶
A job conclusion of cancelled has two causes, and needs.<job>.result
carries the same token for both:
- the job was never scheduled onto a runner, and the platform cancelled it while it waited;
- the job ran and exceeded its
timeout-minutes.
Do not reach for the timeout first. A job the runner pool never picks up
is cancelled roughly 15 minutes after it is queued no matter what
timeout-minutes it declares, so an elapsed time near 15 minutes that
does not match the job's own deadline rules the timeout out rather than
in.
The jobs API settles it. A job that ran reports a runner_name, a
populated steps array, and a file of its own in the run's log archive.
A job that never started reports an empty runner_name, an empty
steps array, and contributes no file at all:
gh api "repos/rvenutolo/linPEAS-flake/actions/runs/<run-id>/jobs" \
--jq '.jobs[] | "\(.name)\t\(.conclusion)\t\(.runner_name)\t\(.steps | length)"'
An empty steps array is not evidence that step data aged out of
retention. Retention drops a whole run at once, so read the array
against a sibling job in the same run: when the other jobs still carry
full step timings, nothing expired and the empty one never started. The
log archive answers the same way — download it and list its entries
rather than reading one job's absence from it as expiry.
A never-scheduled cancel is also unpageable from inside the run. The
notify job that would file the issue needs the runner that was
unavailable, so a scarcity window wide enough to cancel one job
generally cancels the notifier queued behind it too, and the run sits
red with nothing filed. No change to the workflow fixes that; catching
it would take a later run reading the jobs API.
Cron-notify root-cause comments¶
When a notify-workflow-result issue auto-closes after a transient
failure recovers, leave a one-line root-cause comment on the closed
issue (e.g. transient: docker.io 502, transient: cache.nixos.org 5xx). The issue itself is closed; the comment is the durable
record. Future failures of the same shape get triaged faster, and
the closed-issue history doubles as a frequency log.
This is a maintainer-discipline invariant, not a code invariant — no lint enforces it.
dockerhub-sync trigger¶
Triggers: workflow_run of release-on-bump (any completion) + manual
workflow_dispatch. Success filtering happens at the sync job:
- Do NOT reintroduce
push:trigger. - Keep the
if:gate on thesyncjob.
The notify job uses if: always() and reads needs.sync.result, so a
skipped sync is inert in notify-workflow-result while a cancelled one
is filed as an infrastructure failure.
Like the cron-notify rule above, this is a maintainer-discipline
invariant — no lint asserts the trigger shape or the if: gate (the
only script naming this workflow checks token-scope split).
GitHub Pages site invariants¶
-
docs/_data/dashboard.ymlis generated byscripts/gen-dashboard-data.shat site-build time and.gitignored. Committing is a review-blocker. -
scripts/gen-dashboard-data.shenforces (mirrorsbump-linpeas.sh):pin.versionmust match[0-9]{8}-[0-9a-f]{7,40}— hard-fail.pin.urlmust start withhttps://github.com/peass-ng/PEASS-ng/releases/download/— hard-fail. The rendered dashboard links that URL, so an off-prefix pin would point a reader at a download host upstream does not control.- Missing required JSON fields hard-fail with field name; never partial YAML.
- This-repo lookups that are allowed to degrade (
releases/latest, the last-bump PR search, the latestverify-latest-releaserun) are shape-checked before use.gh apiwrites its JSON error body to stdout, so an unchecked error body would publish its missing keys as a literal"null"tag or image ref. A failed, unparsable, or wrong-shaped response degrades to the documented empty/unknownsection and logs aWARNnaming the lookup. - The YAML is written atomically —
make_tempthenmv, never a>redirect onto the final path — so an aborted run cannot leave a truncateddashboard.ymlbehind for the site build to read.
Tested by
tests/gen-dashboard-data.test.shviadashboard-data-testsrequired CI job. The script's eight fixture-injection env hooks arePIN_FILE_OVERRIDE,OUT_FILE_OVERRIDE,UPSTREAM_RELEASE_JSON_OVERRIDE,LATEST_RELEASE_JSON_OVERRIDE,THIS_REPO_RELEASES_JSON_OVERRIDE,UPSTREAM_RELEASES_JSON_OVERRIDE,BUMP_PR_JSON_OVERRIDE, andPARITY_JSON_OVERRIDE. New invariant in script requires matching fixture + scenario. -
pages.yml'sbuildjob intentionally NOT in required-check set. Site bug must not block pin bumps. -
pages.ymluses onlysecrets.GITHUB_TOKEN. New secret requires security-review entry documenting scope + rotation.