Skip to content

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 > 0 of 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 via update-flake-lock.
  • docs-audit-reminder.yml (monthly cron + dispatch) opens a deduped docs-audit issue 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 running just docs-audit-done in 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/** plus scripts/octoscan-scan.sh; composite actions under .github/actions/ are not scan targets). codeql.yml deliberately 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 because required-checks-no-paths forbids 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.yml also runs on push: branches: [main] and release: 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 verify against 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 — local linpeas-pin.json has a malformed .version field.
  • reason=upstream-api-failure — gh api .../releases/latest failed 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 — the check job produced no reason output (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:

if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
  • Do NOT reintroduce push: trigger.
  • Keep the if: gate on the sync job.

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.yml is generated by scripts/gen-dashboard-data.sh at site-build time and .gitignored. Committing is a review-blocker.

  • scripts/gen-dashboard-data.sh enforces (mirrors bump-linpeas.sh):

    1. pin.version must match [0-9]{8}-[0-9a-f]{7,40} — hard-fail.
    2. pin.url must start with https://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.
    3. Missing required JSON fields hard-fail with field name; never partial YAML.
    4. This-repo lookups that are allowed to degrade (releases/latest, the last-bump PR search, the latest verify-latest-release run) are shape-checked before use. gh api writes 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/unknown section and logs a WARN naming the lookup.
    5. The YAML is written atomically — make_temp then mv, never a > redirect onto the final path — so an aborted run cannot leave a truncated dashboard.yml behind for the site build to read.

    Tested by tests/gen-dashboard-data.test.sh via dashboard-data-tests required CI job. The script's eight fixture-injection env hooks are PIN_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, and PARITY_JSON_OVERRIDE. New invariant in script requires matching fixture + scenario.

  • pages.yml's build job intentionally NOT in required-check set. Site bug must not block pin bumps.

  • pages.yml uses only secrets.GITHUB_TOKEN. New secret requires security-review entry documenting scope + rotation.