Skip to content

Auto-update architecture

Three automation groups — the daily pin bump, the release on bump, and the weekly dependency-upkeep pair — keep the pin current and the release artifacts in sync. Two staleness watchdogs sit alongside them: flake-lock-staleness-check watches that the dependency-upkeep pair is still refreshing inputs, and stale-pin-check watches the pin bump.

Daily linpeas pin bump

flowchart TD
  cron["update-linpeas.yml<br/>(daily cron)"]
  api["gh api repos/peass-ng/PEASS-ng/releases/latest"]
  tagcheck["validate tag regex"]
  compare{"upstream tag<br/>== current pin?"}
  urlcheck["validate URL prefix"]
  fetch["curl --location asset_url"]
  digest["cross-check .digest<br/>(hard fail on absent)"]
  hash["nix hash file --sri"]
  write["make_temp + mv<br/>linpeas-pin.json"]
  show["./scripts/refresh-flake-show.sh"]
  pr["gh pr create<br/>chore: bump linpeas to <tag>"]
  automerge["gh pr merge --auto --merge"]
  done(["no-op"])

  cron --> api --> tagcheck --> compare
  compare -- yes --> done
  compare -- no --> urlcheck --> fetch --> digest --> hash --> write --> show --> pr --> automerge

A failure anywhere in that chain files a deduped issue labelled update-linpeas-failure, titled update-linpeas: cron failed, through the notify-workflow-result composite. A no-op day — upstream tag equal to the current pin — collapses to success and files nothing, because push-and-merge skipping is not a failure.

The issue body names the trigger, both job conclusions, and the causes worth checking first: an upstream PEASS-ng API rate-limit, a BUMP_APP_PRIVATE_KEY that is invalid or whose App was uninstalled (which can only affect push-and-merge), the asset-URL prefix check tripping because peass-ng moved their release host, a missing or non-sha256: digest field upstream, or an artifact handoff failure between the two jobs. The rate-limit and missing-digest cases are upstream conditions the next cron tick retries on its own. The credential case and the moved-release-host case each need action first: the expected prefix is a hardcoded literal in scripts/bump-linpeas.sh and again in the workflow's own re-validation step, so a genuine host move fails every subsequent tick identically until both are edited. Re-run manually with gh workflow run update-linpeas.yml once the cause is cleared.

Release on bump

flowchart TD
  trigger["push to main<br/>changes linpeas-pin.json<br/>(release-on-bump.yml)"]
  preflight["preflight:<br/>classify image-mode<br/>(image presence)"]
  release["release:<br/>validate VERSION shape<br/>gh release create --target $GITHUB_SHA<br/>attest + cosign-sign pin file"]
  image_amd64["image-amd64:<br/>build, push, attest<br/>provenance + SBOM, cosign sign"]
  image_arm64["image-arm64:<br/>build, push, attest<br/>provenance + SBOM, cosign sign"]
  manifest["manifest:<br/>multi-arch manifest by digest"]
  verify["verify:<br/>gh attestation verify<br/>(provenance + SBOM)"]
  changelog["changelog:<br/>git-cliff regenerate + PR"]
  notify["notify:<br/>file/close deduped<br/>release-on-bump-failure issue"]

  trigger --> release
  trigger --> preflight
  release --> image_amd64
  release --> image_arm64
  preflight --> image_amd64
  preflight --> image_arm64
  image_amd64 --> manifest
  image_arm64 --> manifest
  release --> manifest
  preflight --> manifest
  manifest --> verify
  release --> verify
  preflight --> verify
  image_amd64 --> verify
  image_arm64 --> verify
  release --> changelog
  release --> notify
  preflight --> notify
  image_amd64 --> notify
  image_arm64 --> notify
  manifest --> notify
  changelog --> notify
  verify --> notify

notify runs on always() and reads the result of every job above it, so it is the observability path for this pipeline rather than a step in it.

Weekly dependency upkeep

flowchart LR
  flakelock["update-flake-lock.yml<br/>compute-lock (read-only)<br/>+ push-and-merge (App token, REST PUT /contents)"]
  renovate["Renovate Friday batch<br/>(action SHAs, Nix pin, octoscan digest,<br/>SchemaStore pin + tracked flake inputs)<br/>minimumReleaseAge: 7 days"]
  pr1["PR: update flake.lock"]
  pr2["PR: action SHA / input bumps"]
  ci["required CI checks"]
  merge["merge-commit on green"]

  flakelock --> pr1 --> ci --> merge
  renovate --> pr2 --> ci --> merge

The daily-bump and weekly-lock diagrams both omit their workflow's notify job: like the release pipeline's, it runs on always() after the others and files or closes a deduped failure issue, so it is observability rather than a step in the flow. The daily-bump diagram also elides the two-job credential split described below: the nodes through write/show run in compute-pin (contents: read, no App key), which hands the computed pin to push-and-merge — the only job holding the App token — via a bump-result artifact that the credentialed job re-validates before use; pr and automerge live there.

No third-party flake-lock action is used: such actions take the write credential as a with: token: input, which would put it inside an externally-controlled action boundary. The split-job design instead confines Nix evaluation to a contents: read job; the push-and-merge job authenticates to the GitHub API as the linpeas-flake-bumper App via a short-lived installation token (actions/create-github-app-token), then commits files via REST PUT /contents. No git push, no PAT in .git/config. REST commits authenticated by an App installation token are auto-signed by GitHub's web-flow GPG key, so the bump branch satisfies required_signatures on main.

Flake-input staleness watchdog

Both mechanisms above refresh inputs, and neither announces having stopped. A disabled workflow, a broken trigger, or a Renovate manager that stops opening PRs leaves the inputs the affected mechanism refreshes frozen while every check stays green. Two of those are live risks here: a broken trigger — a Renovate branch-prefix change would stop the identify job's renovate/ branch-prefix gate in renovate-flake-lock-refresh.yml from ever passing — and a manager that stops opening PRs — Renovate in Mend silent mode can leave pre-commit-hooks far past its staleness tier, with nothing in either case saying so. (Failures inside the workflow do announce themselves: a bot login-shape change fails the identify job on an author the classifier rejects, a Renovate title-shape change has identify report the PR as unmapped, and a refresh that fails downstream fails compute-refresh or push-refresh; notify files an issue for each.)

scripts/check-flake-lock-staleness.sh, run daily by flake-lock-staleness-check.yml, watches the freeze rather than any single mechanism: it fails when a top-level input's locked.lastModified is older than the bound declared for that input.

The workflow is a scheduled issue-filer, deliberately not a required check. A gate keyed on wall-clock age turns every unrelated PR red the moment a cron runs late, which charges contributors for infrastructure lateness. Nothing on the pull-request path asserts an age: the check's harness, which the required harness-group job runs, ends with a live scenario against the repo's own flake.lock, but it pins "now" to that lock's newest lastModified and accepts a stale verdict — it asserts only that every directly-pinned input still has a bound declared, resolves to a node rather than through follows, and carries a lastModified in the timestamp form the script header states.

locked.lastModified is an upstream commit time, not a record of when this repo last checked, which is what makes the bounds uneven rather than arbitrary. For a high-churn input, upstream moves far faster than the bound, so an old lock can only mean nobody refreshed it. For a low-churn input, an old lock most likely means upstream is quiet, and a tight bound would report that as drift. Each input therefore gets the bound its upstream churn can support:

Tier Bound Inputs Why
Fast 14 days nixpkgs, nixpkgs-unstable Branch-tracked against an upstream committing at least daily. Two missed weekly cron cycles is already past anything healthy.
Slow 120 days flake-parts, treefmt-nix, pre-commit-hooks flake-parts and treefmt-nix commit in bursts; pre-commit-hooks is rev-pinned, so Renovate is the mechanism that moves it and this bound is what surfaces a Renovate manager that has gone quiet. Loose enough that ordinary quiet never fires, tight enough that a stopped mechanism still surfaces.

Scope is the top-level inputs only. A transitive node's rev is chosen by its parent's pin rather than by anything this repo runs, so its age reports on somebody else's release cadence — flake-compat moves only when git-hooks.nix repins it, and no mechanism here is failing. Including it would mean a check that goes red on somebody else's quiet, which nobody here can act on.

An input present in flake.lock that the bound table does not name is an operational error, not a pass: a threshold table rots when an input is added and not registered, and the silent-pass version of that rot is a new input nobody is watching.

Dependency-PR merge policy

Every class of dependency bump merges unattended once the required check set is green. There is no reviewer gate on any class, and no per-PR lever to introduce one: Renovate re-arms platformAutomerge on its next run over the repo, so gh pr merge --disable-auto does not hold. A bump that must not land is held by making a check fail, or by closing the PR and letting the dependency-dashboard entry re-propose it.

What stands between a bump and main:

Gate Applies to What it catches
minimumReleaseAge: 7 days every Renovate PR a release yanked or patched shortly after publication
SHA / digest pinning GitHub Actions, octoscan, cachix/git-hooks.nix, SchemaStore a mutable tag repointed under the pin
check-pin-digest-provenance.sh GitHub Actions, octoscan a digest-only repoint where the version label did not move
required check set every PR build, harness, lint, and attestation failures reproducible at PR time
renovate-flake-lock-refresh.yml nixpkgs, pre-commit-hooks a flake.nix bump whose lockfile refresh writes outside the paths it may commit — the job fails closed and the PR stays red

The gate this policy deliberately does not have is a human reading the diff. That trade is sharpest for the nixpkgs stable-branch bump, where part of the fallout — CRITICAL CVEs in new image base layers, a bundled tool renamed out of the image, the dashboard rebuild — surfaces on a later cron run rather than on the bump's own checks. A green check set is therefore not proof that a stable-branch bump is inert; it is proof that nothing reproducible at PR time is broken. Recovery for the rest is a revert, and the fallout classes are tabulated in flake-input-bumps.md.

Where this site fits

The Pages workflow runs:

  • On every push to main (catches docs and code changes).
  • On every PR to main — build only; deploy and notify are gated off for pull requests, so a PR renders the site without publishing it.
  • On every release (catches release-on-bump pin landings).
  • Last slot in the daily window, after update-linpeas and stale-pin-check, so the dashboard reads a settled state. See CI — cron schedule.
  • On manual workflow_dispatch.

The Pages site is not in the protect-main ruleset's required check set; a Pages failure must not block pin bumps. See CI.

Pin-diff isolation

Only scripts/bump-linpeas.sh may mutate linpeas-pin.json. Any commit landing on main that changes the SRI hash, pin URL, or pin version must isolate that change to linpeas-pin.json. The only automatic trigger for release-on-bump.yml is push.paths: [linpeas-pin.json] (a manual workflow_dispatch recovery and backfill path also exists). A pin change that bundles in unrelated files is what this rule forbids — the trigger may or may not fire depending on the merge-commit diff shape — and a pin change that arrives via a different script breaks the same contract.

Enforced by scripts/check-pin-diff-isolated.sh via the lint-doc-invariants CI job (member check pin-diff-isolated) + pre-commit hook. Lint asserts exactly one writer (scripts/bump-linpeas.sh) under scripts/.

nix/linpeas.nix pin invariants

pin.version must match [0-9]{8}-[0-9a-f]{7,40}. pin.url must start with https://github.com/peass-ng/PEASS-ng/releases/download/. pin.version must also be the release-tag path segment of pin.url, so a hand-edited pin cannot declare one version while fetching a different release's artifact. Flake-eval-time asserts because pin.version interpolates into derivation names, docker tags, OCI labels.

Upstream peass-ng versioning-scheme change: update the regex carefully and keep a shape check. Every file that has to move together is listed under Canonical pin shape.

Canonical pin shape

Upstream tags the linpeas release as YYYYMMDD-<hex>, and the repo matches that shape with one regex, ^[0-9]{8}-[0-9a-f]{7,40}$. The regex has no single home: a Nix assert, a git-cliff config key, several scripts and several workflow steps each carry their own copy, because workflow YAML cannot source a shell library and a Nix assert cannot read one either. The duplication is therefore permanent, and the risk it carries is that a versioning-scheme migration updates some copies and leaves others behind.

The list below is derived from the tree on every run, so it names the copies that exist rather than the copies someone remembered. Regenerate it with scripts/refresh-pin-parity.sh; do not hand-edit between the markers.

Enforcement and configuration:

  • .github/actions/notify-workflow-result/action.yml
  • .github/workflows/release-on-bump.yml
  • .github/workflows/stale-pin-check.yml
  • .github/workflows/update-linpeas.yml
  • cliff.toml
  • nix/linpeas.nix
  • scripts/bump-linpeas.sh
  • scripts/check-cliff-tag-pattern.sh
  • scripts/gen-dashboard-data.sh
  • scripts/refresh-pin-parity.sh

Documentation:

  • docs/architecture/auto-update.md
  • docs/architecture/ci.md
  • docs/development/changelog.md
  • docs/invariant-index.md
  • docs/security/trust-model.md

Test fixtures and harnesses under tests/ carry the shape too and are excluded from these lists; a scheme migration updates them alongside the check each one drives.

Release VERSION shape validation

release-on-bump.yml rejects any tag outside the canonical pin shape ^[0-9]{8}-[0-9a-f]{7,40}$ (YYYYMMDD-<hex>) before calling gh release create.

Linpeas-pin release-trigger

Any change to linpeas-pin.json that lands on main MUST cause a new release to be cut by release-on-bump.yml. The weekly verify-latest-release cron re-verifies the published pin asset's attestation and re-checks the upstream SRI hash; it does not compare the release asset against the in-tree pin, so a pin change that lands without firing the release pipeline is prevented by the trigger contract below rather than detected downstream.

Paired with the pin-diff-isolated invariant: the only mutator (bump-linpeas.sh) writes only linpeas-pin.json, so any pin change naturally satisfies the release-on-bump.yml paths: [linpeas-pin.json] trigger.