Flake-input bump runbook¶
This page is the triage runbook for the Renovate PRs that touch
flake-input pins in flake.nix:
cachix/git-hooks.nix— master HEAD tracker. Fires when upstream master has moved and the new head has cleared the global 7-dayminimumReleaseAgequarantine.NixOS/nixpkgs— stable-branch tracker. Fires when the next NixOS GA tag (YY.MM) lands plus the global 7-dayminimumReleaseAgequarantine. Blast: the linpeas runtime derivation and the image's bundled payload. Of the tooling, thenixpkgs-hammeringlint (see the ownership note below).nixpkgs-unstable— also declared, but Renovate does not track it. Its URL names a branch that never renames (github:NixOS/nixpkgs/nixos-unstable), so the weeklyupdate-flake-lock.ymlcron's barenix flake updatealready floats it and lands it as achore: update flake.lockPR. A Renovate manager for it would be redundant with that cron, and worse: its only possible edit is to replace the branch name inflake.nixwith a fixed rev, which freezes the input and silently stops the manager from ever matching again. Blast: the whole tooling layer — devShell, CI hooks, formatters, linters, mkdocs. Never toucheslinpeas-image.flake-partsandtreefmt-nix— branch-tracked and likewise untracked by Renovate, so the same weeklynix flake updatefloats them onto thechore: update flake.lockPR. Both carry the 120-day staleness bound rather than the 14-day one, because upstream commits in bursts. Blast:treefmt-nixowns the formatter wiring, so it lands in the same fallout classes as anixpkgs-unstablebump;flake-partsmoves the flake's module plumbing, where breakage surfaces as an eval failure rather than a formatting delta.
Which input owns what is decided in flake.nix and nix/, not by
branch name. flake.nix makes both treefmt-nix and pre-commit-hooks
set inputs.nixpkgs.follows = "nixpkgs-unstable"; nix/devshell.nix
builds the shell from pkgs-unstable; nix/treefmt-config.nix sets
treefmt.pkgs = pkgs-unstable and otherwise reads stable pkgs.lib
only; and nix/hooks/* reads pkgs.lib only,
with one exception — the nixpkgs-hammering hook exports
NIX_PATH="nixpkgs=${inputs.nixpkgs}" so nix/hammer-shim.nix evaluates
the linpeas derivation against stable. The stable-pkgs consumers in the
tree are nix/linpeas.nix, the linpeas-image derivation in
nix/image.nix (the site derivation in the same file builds from
pkgs-unstable), and that shim. A stable bump
therefore cannot move nixfmt,
prettier, mdformat, shfmt, taplo, just, zizmor, statix,
deadnix, actionlint, shellcheck, or mkdocs.
Both Renovate bumps merge unattended once the required check set is green;
see Dependency-PR merge policy
for the gates that replace a reviewer. The managers do pure text
substitution on flake.nix and do not refresh flake.lock, so a
bump PR is red until the lockfile catches up.
The lockfile refresh is performed automatically by the
renovate-flake-lock-refresh workflow, which runs on every ci
completion and acts only when the run was a same-repo pull_request
on a renovate/* branch. It detects the bumped input
from the PR title by handing it to
scripts/classify-renovate-flake-input.sh, whose case arms recognize
three title shapes — cachix/git-hooks.nix, NixOS/nixpkgs-unstable,
and NixOS/nixpkgs, matched case-insensitively against a lowercased
title, in the arm order the auto-refresh section
explains. The workflow then runs nix flake update <name> and commits
the refreshed flake.lock — plus any regenerated lock-derived docs,
one signed REST PUT /contents commit per file. Watch the PR for a follow-on
chore(flake): refresh flake.lock for <input> commit (and
chore(flake): update <doc> for <input> lock refresh siblings) a few
minutes after ci first goes green.
The manual runbook below is the fallback when the auto-refresh
does not fire, and the reproduction path when a landed bump breaks
something — most often because the PR title format changed and
no longer matches a case arm in
scripts/classify-renovate-flake-input.sh, or because the author login
GitHub reports moved out of step with
scripts/classify-renovate-pr-author.sh. Either way an issue is filed
under the renovate-flake-lock-refresh-failure label; pursue the manual
steps below in the meantime.
Why a runbook¶
renovate.json defines custom-regex managers that bump the URL fragment
in inputs.*.url. Renovate's hosted SaaS does not run
postUpgradeTasks (nix flake update is not in Mend's allowed-command
list), and switching to self-hosted Renovate for one command would add
significant ops surface for a solo-maintainer repo.
renovate-flake-lock-refresh.yml closes that gap unattended; this
runbook covers the case where it does not fire, and the case where a
bump that already landed breaks something.
Bump cadence, which sets how often either case comes up:
cachix/git-hooks.nix: whenever upstream master advances past the 7-day quarantine, at most once per Friday Renovate run.NixOS/nixpkgs: twice per year (MayYY.05, NovemberYY.11).nixpkgs-unstable: weekly via theupdate-flake-lock.ymlcron, not Renovate. Carries the tooling layer.
Expected breakage surface¶
Stable (NixOS/nixpkgs) bump — runtime and image base¶
A major NixOS/nixpkgs bump (e.g. YY.05 → YY.11) rotates the
image's bundled runtime payload and the linpeas derivation's build
inputs. It does not touch the devShell or CI tooling — those follow
nixpkgs-unstable (see the ownership note at the top of this page), so
a stable bump cannot move the formatters or the workflow and shell
linters. The one lint it can move is nixpkgs-hammering, which evaluates
the linpeas derivation against stable nixpkgs and so fails flake-check
on the bump PR itself. The visible
fallout falls into a small set of recurring classes. A green check set
does not clear the list: some of these surface on a later cron tick
rather than on the bump's own checks. Walk the list when one does.
- CRITICAL CVEs in image base layers.
image-cve-scan-trivyandimage-cve-scan-grype(image-cve-scan.yml, weekly cron plus a push trigger on the paths that change the image) are the canonical surface. The new nixpkgs may carry an unfixedCRITICALCVE incoreutils,bashInteractive,gnused, etc. The CRITICAL-fail gate flags this loudly; the remediation is "wait for nixpkgs to patch + bump again", not a code change here. Skim the Security tab post-merge. - Image base-layer tool renames. If a tool the image expects
is gone from the new nixpkgs (rename, removal,
refactor-to-a-module),
image-smoke's "verify external tools present in image" step (command -vforgrep sed awk find ps) or its real-target-o system_informationscan will surfacecommand not found. The-hrun does not: a missingsed,awk,find, orpsleaves-hsilent. Walkpkgs.buildEnv.pathsinnix/image.nixagainst the smoke output. gh attestation verifytrust-root change lag. When Sigstore changes its trusted root in a way older clients cannot parse (a new key type, for example), a runner image whoseghCLI predates the change fails every attestation verify (see Tools needed). A stable bump does not affect this: the workflows use the runner'sgh, and the cosign steps run the lock-pinned.#cosign, which comes fromnixpkgs-unstable(see the unstable section below). A coincident change can still look like attestation drift. The step log shows a trusted-root load error (the errors under Tools needed); confirm that before assuming drift. Aghfailure of this kind clears once the runner image ships aghthat parses the new root.
Step 5 of the step-by-step below covers most of this surface as a symptom → fix lookup table; use this section to anticipate before the PR arrives, and the table to triage after CI fails.
Unstable (nixpkgs-unstable) bump — the whole tooling layer¶
Tooling-only. Never touches the image runtime payload. This is where every formatter, linter and site-build regression comes from, and it lands weekly on the cron's lockfile PR rather than on a Renovate one:
- Formatter rewrites.
nixfmt,prettier,mdformat,shfmt,taplo,just. Frequent; usually one-line whitespace deltas, but a new minor version can rewrite wrapping or quoting conventions across Markdown / YAML / JSON / Nix / shell / TOML. Accept vianix fmt; do not pin around it. - New linter rules.
zizmor,statix,deadnix,actionlint,shellcheck. A newzizmormajor changes rule severities or adds rules that surface on existing workflows, failingnix flake check. Fix forward; raise--min-severityinnix/hooks/linters.nixonly as a last resort, and never abovelowwithout a security-review entry. - mkdocs-macros strictness. The site build
(
nix build "path:$(pwd)#site") aborts on a literal{{ ... }}outside a Jinja2 raw block. New plugin behavior occasionally starts treating a non-template block as macro input. Wrap the block in raw tags; do not loosen--strict. - mkdocs --strict warnings. Plugin upgrades can promote warnings to errors (broken anchors, missing nav entries, deprecated options). Fix forward; pin the misbehaving plugin only as a last resort and document the pin reason in the same PR.
- pre-commit-hooks lib drift.
cachix/git-hooks.nixfollowsnixpkgs-unstable, so when lib symbols move under it the hooks it enables sometimes break before that pin's own bump lands. Surfaces asnix flake checkfailures unrelated to any workflow change. See "Interaction between the pins" below. - cosign client change. The release and verification workflows run
the lock-pinned
.#cosign, whichnix/packages.nixtakes fromnixpkgs-unstable, so this bump changes the client that signs and that loads Sigstore's trusted root. What a client prints when it cannot load that root, and what that error does and does not prove, is under Tools needed.
Out of scope for unstable bumps (these only happen on stable):
- Image base-layer rotation / new bundled
coreutilsversions. - CRITICAL CVEs in runtime payload.
When the Renovate PR arrives¶
PR title looks like one of:
Update cachix/git-hooks.nix digest to <new-SHA>Update NixOS/nixpkgs to <version>
Only the dependency-name substring is matched, so the surrounding wording
is not load-bearing — the identify job shells out to
scripts/classify-renovate-flake-input.sh, whose case arms glob on
cachix/git-hooks.nix, NixOS/nixpkgs-unstable and NixOS/nixpkgs
(matched against a lowercased title, so capitalization does not matter).
A title that stops carrying one of those substrings is classified as
unmapped: no refresh is attempted, and notify files an issue under the
renovate-flake-lock-refresh-failure label that links the PR; the new
title shape is read from there.
The classifier keeps its NixOS/nixpkgs-unstable arm even though no
manager emits that title; the auto-refresh
section explains why deleting
it as dead code would refresh the wrong input.
Diff: exactly one line in flake.nix changed. flake.lock is not
touched. CI required checks fail on flake-check (lock-out-of-date
error) until the lockfile catches up — normally through the
auto-refresh above, and through the steps below when it does not
fire. A bump therefore cannot auto-merge on the strength of the
flake.nix line alone.
Every one of these bumps repoints an input's source, which is what the
flake.lock input-provenance gate
(scripts/check-flake-lock-provenance.sh, a member check of the
lint-doc-invariants CI job) exists to catch. It passes here because
the repoint is declared: the gate reads flake.nix on both sides and
tolerates a lock move for an input whose declared url moved with it,
logging a note that names both. The corroboration is per input name, so a
lock carrying a second, undeclared repoint alongside the declared one still
fails. A declared move is also exempt from the gate's revision-ancestry
probe, which is why a channel move (whose new rev sits on a different
release branch and would compare diverged) or a pin to an older commit
(behind) does not trip it, while every other GitHub-hosted node whose rev
moved in the same lock refresh is still verified as a fast-forward. See
the gate's section in the trust model.
Step-by-step¶
1. Check out the PR locally¶
2. Refresh flake.lock¶
For a cachix/git-hooks.nix bump:
For a NixOS/nixpkgs bump:
Both commands rewrite flake.lock in place. Confirm the diff is sane
(git diff flake.lock) — should show new lastModified /
narHash / rev for the relevant node, nothing else.
3. Run formatter¶
A newer nixpkgs-unstable ships newer treefmt + prettier + shfmt.
These will sometimes rewrite tracked files (markdown line wraps,
blank-line trimming, etc.). Run the formatter explicitly so the diff
lands in this PR rather than fighting CI. A stable-only bump leaves the
formatters untouched, so this step is a no-op there:
Stage anything that changes:
4. Regenerate the lock-derived docs¶
Several tracked docs are generated from evaluated flake state, so the
refreshed lock can leave them stale. Run each generator named in the
lock-writing workflow's LOCK_DERIVED_GENERATORS list and commit the
changed docs alongside the lock:
nix develop --command bash scripts/refresh-flake-show.sh
nix develop --command bash scripts/refresh-treefmt-config.sh
See Lock-derived docs travel with the lock for what asserts that list.
5. Check expected side-effect classes¶
Use the table as a checklist for any nixpkgs bump. The first four rows
belong to nixpkgs-unstable, which owns the tooling layer; the last two
can fire on a stable bump:
| Class | Symptom | Fix |
|---|---|---|
prettier |
YAML / Markdown / JSON whitespace diffs | accept the rewrite via nix fmt |
mkdocs-macros strictness |
nix build "path:$(pwd)#site" aborts on a {{ ... }} literal inside a code block |
wrap the offending block in {% raw %}...{% endraw %} (mirrors the convention in docs/architecture/ci.md) |
zizmor major version |
nix flake check fails on a workflow finding the older version did not surface |
fix the workflow or, as a last resort, adjust --min-severity in nix/hooks/linters.nix (do not raise above low without a security-review entry) |
mkdocs --strict |
Build fails on a new plugin warning | fix forward; pin the misbehaving plugin only as a last resort and document the pin reason in the same PR |
nixpkgs-hammering |
nix flake check fails on a nixpkgs idiom the older stable branch did not flag, against the linpeas derivation |
fix nix/linpeas.nix forward; suppress in nix/hammer-shim.nix only with a reason in the same PR |
| linpeas-image base layers | image-cve-scan-trivy / image-cve-scan-grype SARIF changes (the merge itself triggers a scan, since flake.lock is a trigger path); image-smoke could surface command not found regressions |
smoke test locally (step 8) — adjust buildEnv.paths in nix/image.nix only if a required tool genuinely disappeared from nixpkgs |
cachix/git-hooks.nix bumps in isolation usually only hit the zizmor
row and only when the pre-commit-hooks repo changes hook versions in
lock-step.
6. Build everything¶
nix build .#linpeas --print-build-logs
nix build .#linpeas-image --print-build-logs
nix build "path:$(pwd)#site" --print-build-logs
The path:$(pwd)#site form is required for the site derivation —
it bypasses the git filter so the gitignored docs/_data/dashboard.yml
is visible to the build.
7. Run the local suites¶
just verify runs the batched lint groups, the harness group, the
doc-freshness checks, and the standalone required-check enforcers its
justfile recipe lists (the changelog checks among them). Those
enforcers have fixture harnesses of their own, which the recipe does not
run; they run in the enforcers' CI jobs. The action-driven checks are not
part of just verify:
markdownlint, typos and editorconfig run as CI jobs and, locally,
through the pre-commit hook set (just lint / just check); commitlint
runs as a CI job and, locally, only through the installed commit-msg git
hook on an actual commit (a commit-msg-stage hook, which
pre-commit run --all-files skips); gitleaks, trufflehog,
dependency-review and lint-pr-title have no local hook and run only in
CI. Of the required-check enforcers, only
check-flake-systems-eval.sh runs nowhere but CI; the drift-check
enforcers (daily: check-flake-lock-staleness.sh; weekly:
check-scorecard-threshold.sh) likewise run as enforcement steps only
in CI, because each answers to an input from outside the tree — the
wall clock, against which flake.lock's locked.lastModified is
measured, and the scorecard CLI's JSON on stdin, which only the
scheduled run produces. Locally those two
drift-check enforcers' harnesses run, and the staleness harness's last
scenario runs its enforcer against the live flake.lock — pinned off
that lock's own newest lastModified, so it reads the tree without
asserting an age;
check-flake-systems-eval.sh and its
harness run only in the flake-check job. Individual
harnesses can still be run directly
(nix develop --command bash tests/<name>.test.sh) while iterating on
a single failure. If any fail, do not disable the test — debug
the regression. The lint scripts encode binding security invariants.
8. Image smoke¶
Reproduce the two behavioral checks image-smoke runs after -h. -h alone is not
a smoke test: it exits 0 and stays silent when sed, awk, find, or
ps is missing from the image.
nix build .#linpeas-image --out-link result-image
VERSION="$(jq --raw-output .version linpeas-pin.json)"
docker rmi "rvenutolo/linpeas:${VERSION}" 2>/dev/null || true
docker load --input result-image
# 1. Every external helper the image deliberately bundles is present.
for tool in grep sed awk find ps; do
docker run --rm --entrypoint /bin/sh "rvenutolo/linpeas:${VERSION}" \
-c "command -v ${tool} >/dev/null" || echo "FAIL: ${tool} missing"
done
# 2. A real check against a separate target container emits no
# "<cmd>: command not found" line.
docker run --detach --name linpeas-target --rm ubuntu:24.04 sleep 300
docker run --rm --pid=container:linpeas-target --volumes-from linpeas-target \
"rvenutolo/linpeas:${VERSION}" -o system_information 2>&1 \
| grep --count --extended-regexp ': command not found'
docker rm --force linpeas-target
# Expect no FAIL line from 1 and a count of 0 from 2
A FAIL line or a non-zero count means a tool the image expects is
missing from the new nixpkgs. Compare pkgs.buildEnv.paths in
nix/image.nix against the missing tool's package name — usually a
rename. Update the path list in the same PR.
9. Run flake check¶
All pre-commit hooks must pass. Representative ones for a flake-input
bump: actionlint, deadnix, nixfmt, treefmt, shellcheck,
statix, yamllint, zizmor, nixpkgs-hammering. Sandbox-guarded
hooks (uses-sha-pinned, the *-fresh family, and the other
NIX_BUILD_TOP-bailing entries) no-op under nix flake check — most
are covered by just verify and the doc-freshness / lint-group CI
jobs instead, though not all: patch-tag-pins is pre-commit only,
check-cron-table's live coverage is the cron-table-drift-check
workflow, octoscan's live coverage is the octoscan.yml
workflow (its harness runs test-only in harness-group), and the two
actionlint canaries (actionlint-shellcheck-active,
actionlint-pyflakes-active) run as pre-commit hooks, with
harness-group running their harnesses — the hook's own default
invocation plus a check that the hook's file filter still watches the
pin.
The full, generated list is in
Git workflow → Pre-commit hooks.
10. Commit the refresh¶
git add flake.lock <any-side-effect-files>
git commit -m "chore(flake): refresh flake.lock for <pin name>"
git push
treefmt is wired as a pre-commit hook — it will run on every staged
file change. If it rewrites something, re-stage and commit.
11. Wait for CI¶
Watch the PR's required checks. On green the PR merges itself —
automerge is scoped to every flake-input manager. To stop a bump
from landing, close the PR: disabling auto-merge does not hold,
because Renovate re-arms it on its next run over the repo.
Note: every commit on the branch must independently satisfy Conventional
Commits (commitlint is a required check) and be signed
(required_signatures is enforced), since each lands verbatim on
main under the merge-commit-only ruleset. The PR title becomes the
merge-commit subject and must itself satisfy Conventional Commits
(lint-pr-title is a required check).
12. Post-merge¶
For NixOS/nixpkgs bumps specifically:
- The CVE-scan SARIF on
mainupdates on the merge itself:image-cve-scan.ymltriggers on a push touchingflake.lock, so a nixpkgs bump rescans without waiting for the Friday cron. Surfacing CVEs is advisory only (theimage-cve-scan-trivyandimage-cve-scan-grypejobs are intentionally outside required-checks), so the run does not gate the merge — skim the Security tab for any newCRITICALrows once it finishes. The remediation path for an unfixed base-layer CVE is the next nixpkgs bump. - The Pages cron (daily) will rebuild the dashboard on its next tick. Push-trigger and release-trigger also rebuild immediately.
- The next
update-flake-lock.ymlcron run (weekly, Friday) will refresh within-YY.MMpatches automatically.
pre-commit-hooks SHA parity¶
flake.nix embeds the cachix/git-hooks.nix revision in the
pre-commit-hooks input URL so Renovate can track it, and flake.lock
records the same revision as pre-commit-hooks.locked.rev. The two must
agree: a URL bumped without a lock refresh (or the reverse) evaluates one
revision while advertising another. Enforced by
scripts/check-pre-commit-hooks-sha-parity.sh, wired as the
pre-commit-hooks-sha-parity pre-commit hook and as a member check of the
lint-script-hygiene CI job.
Interaction between the pins¶
If a NixOS/nixpkgs bump and a cachix/git-hooks.nix bump arrive in
separate Renovate PRs, the order matters when the new nixpkgs lib
adds or removes something git-hooks.nix depends on.
nixpkgs-unstable bumps are independent and do not affect the image;
they arrive on the cron's own lockfile PR and can land in any order
relative to the other two.
The safe order:
- Bump
nixpkgsfirst. This forces anylibdrift to surface on a single PR. - If
flake checkfails becausegit-hooks.nixis now incompatible, bumpgit-hooks.nixin the same PR (rebase on top of the nixpkgs PR before merging). - Then close out the standalone
git-hooks.nixPR.
If both PRs land cleanly when merged independently, no action needed.
That order is advisory, not enforced: both PRs auto-merge on green, so
whichever goes green first lands first. The failure mode is benign —
if a landed nixpkgs bump makes the pinned git-hooks.nix revision
incompatible, the git-hooks.nix PR rebases onto it, flake check
goes red, and the PR sits until the incompatibility is fixed forward.
What a green check set proves¶
Every input merges on green. What that green establishes differs by input:
- Stable (
nixpkgs) bump: the weakest of the three. Thelinpeas-imagebuild and the bundled-binary inventory are covered at PR time, but several breakage classes above are not reproducible then — CVE-scan output and the dashboard rebuild both resolve later. Treat the first weekly cron cycle after a stable bump as part of the bump. - Unstable (
nixpkgs-unstable) bump, which reachesmainon the cron's lockfile PR rather than a Renovate one, carrying anyflake-partsandtreefmt-nixmovement with it: close to conclusive. Formatter and linter churn is exactly what the required checks execute, and the image build is unaffected by definition (the image derivation reads stablepkgsonly — see the ownership note at the top of this page). cachix/git-hooks.nixbump: conclusive for the hook config, which is the entire surface the input touches.
update-flake-lock credential split¶
update-flake-lock.yml mirrors the update-linpeas.yml split:
compute-lockjob haspermissions: contents: readand must not referencesecrets.BUMP_APP_PRIVATE_KEYoractions/create-github-app-token. Nix-evaluating actions confined here.push-and-mergejob uses onlyactions/*SHAs plus the repo-widestep-security/harden-runnerpin (actions/checkout,actions/download-artifact,actions/create-github-app-token,step-security/harden-runner). No third-party action without a security-review entry.- Lock updates run via
nix flake updatein the read-only job, not via any third-party flake-lock-bumping action. - App installation token flows only to
gh api/gh prviaGH_TOKEN. Nogit push. Commit lands via RESTPUT /contents→ web-flow signed. flake.lockartifact carries JSON shape guard:push-and-mergeverifies.nodes | type == "object"before committing.
renovate-flake-lock-refresh auto-refresh¶
renovate-flake-lock-refresh.yml auto-completes the post-Renovate-PR
lockfile-refresh step that hosted Renovate cannot run itself (no Nix
on Renovate's SaaS runners; no postUpgradeTasks allowlist).
Trigger: workflow_run of ci completing (any branch — the on:
block carries no filter). The identify job gates on ALL of:
- PR author is the Renovate App, decided by
scripts/classify-renovate-pr-author.sh. GitHub reports the same App under three spellings —app/renovatefromgh pr view --json author,renovate[bot]from the REST and GraphQL APIs, and a barerenovatefrom a self-hosted legacy install — so the classifier normalizes one optionalapp/prefix and one optional[bot]suffix and then matchesrenovateexactly. A login that merely containsrenovateis rejected: this workflow pushes commits to a PR branch, andrenovateis a claimable username shape. Unlike the other gates below, an author that does not match FAILS theidentifyjob rather than skipping it — every PR reaching this point is on arenovate/branch, so an author Renovate does not own is an anomaly, and a silent skip would be indistinguishable from a login-shape change disabling the whole workflow undetected. - The workflow is running in
rvenutolo/linPEAS-flakeitself (github.repositoryguard), keeping repo forks inert. - PR head branch starts with
renovate/. - The triggering
cirun's own event waspull_request— aworkflow_runfired by a push or dispatch is ignored. - The PR branch lives in this repo
(
head_repository.full_name == github.repository): a fork-hardening gate, since a later job in the workflow holds an App installation token. - PR diff touches
flake.nix. - PR title contains a known dep name (
cachix/git-hooks.nix→pre-commit-hooksinput;NixOS/nixpkgs→nixpkgsinput). The classifier also mapsNixOS/nixpkgs-unstable→nixpkgs-unstableand matches it before stable, because the stable string is a substring of the unstable title. No manager emits that title now, and the arm stays for exactly that reason: dropping it would route an unstable title to the stable arm.
Adding a new auto-refreshable input requires three coordinated
edits in the same PR: (1) extend the case arms in
scripts/classify-renovate-flake-input.sh, which the identify job
invokes (and extend its test harness alongside; the same pairing applies
to scripts/classify-renovate-pr-author.sh if a login shape moves),
(2) add a Renovate customManager in renovate.json,
(3) extend the manual fallback runbook in
docs/architecture/flake-input-bumps.md.
Credential split mirrors update-flake-lock.yml:
identifyjob:permissions: contents: read+pull-requests: read(it reads PR author, branch, diff, and title).compute-refreshjob:permissions: contents: read. Neither references the App key. Untrusted Nix-evaluating actions confined tocompute-refresh.push-refreshjob: holdsBUMP_APP_PRIVATE_KEYonly. Commits refreshedflake.lockto PR branch via RESTPUT /contents→ web-flow signed by GitHub. Nogit push.
Loop-breaker: compute-refresh's diff step sets changed=false when
nix flake update produces no diff, which skips push-refresh
outright. Inside push-refresh a second, per-path guard compares each
committable path's git hash-object against its blob SHA on the
branch and skips the ones already current; zero commits is a clean
exit, not a failure. Protects against the ci → refresh → ci cycle.
Reporting: the notify job runs whenever identify ran, and
scripts/classify-refresh-notify-result.sh decides what the run
amounts to — failure files the deduped
renovate-flake-lock-refresh-failure issue, success closes it, and
skipped is inert in the composite. The verdict lives in a script
rather than in the job's if: gate because a gate reading identify's
outputs cannot see an identify that failed: a failed job sets no
outputs, so its failure and its ordinary early exit look identical from
the gate. That matters most here, because this workflow reacts to
someone else's PR — a red run in the Actions tab has no watcher at the
moment it goes red, and the PR it was meant to repair sits with a
failing flake-check instead. A combination the job graph cannot
produce classifies as failure too: an issue naming a drifted workflow
beats the silence. tests/classify-refresh-notify-result.test.sh holds
the matrix, including both ends of it — a failed identify reports, and
a ci completion that is not a Renovate flake bump stays silent.
Not in required-checks. Lockfile refresh on a PR cannot block the PR's own merge gate (chicken-and-egg).
Lock-derived docs travel with the lock¶
Several docs in this repo are generated from evaluated flake state, so a
flake.lock bump can make them stale without touching any other tracked
file — docs/reference/flake-outputs.md embeds the package versions
nix flake show renders, and a nixpkgs bump moves them.
Every workflow that writes a lock therefore regenerates that set
alongside it and commits each changed file as its own signed commit.
Two workflows do: update-flake-lock.yml, which opens the weekly bump
PR, and renovate-flake-lock-refresh.yml, which refreshes the lock on a
Renovate flake-input PR. Each declares the set in two workflow-level
env lists: LOCK_DERIVED_GENERATORS names the generators it runs, and
COMMITTABLE_PATHS bounds what the credentialed job is allowed to
commit — the compute job holds no write credential, so that list, not the
artifact it uploads, is the trust boundary.
The second list is not written by hand against the first. Every
generator already declares what it writes in its own comment header —
@generates <path> for a file it owns outright and
@generates-block <path> for a region it splices into a hand-authored
file — and the committable set is exactly flake.lock plus those
declared outputs. Both annotation kinds count: the split between whole
file and spliced block is a judgment about who owns the surrounding
prose, not about who writes the bytes. flake.lock is on the list
because the workflow writes it directly rather than through a
generator.
scripts/check-lock-derived-docs.sh asserts the two stay in agreement
with the hooks that declare the dependency. It discovers its subjects
rather than naming them: every workflow carrying a lock update in a run
block must declare both lists, every freshness hook whose files regex
names flake.lock must have its generator in that workflow's
LOCK_DERIVED_GENERATORS, and every entry there must be backed by such a
hook. A hook that names flake.lock a trigger while naming no generator
fails the same way — the two halves of one declaration disagreeing is
drift, not a hook the lint may quietly ignore. A workflow declaring the
lists while running no lock update fails too: the lists outlived the step
they bounded, and nothing reads them. Adding a lock-derived generator
without teaching every lock-writing workflow to run it fails the lint
rather than surfacing later as a PR that cannot merge.
It asserts the committable binding in both directions, per workflow. A
path a listed generator declares but COMMITTABLE_PATHS omits is drift:
the compute job regenerates the doc, the credentialed job may not commit
it, and the doc reaches the PR exactly as stale as if the generator had
never run — on a branch nobody is watching, where the only signal is the
required freshness gate failing after the fact. A COMMITTABLE_PATHS
entry no listed generator declares is drift in the other direction: it
widens what the credentialed job may commit past anything the bump
produces. So is a listed generator that declares no output at all —
running a generator whose writes are bound to nothing puts its doc
outside the check entirely, which is the first failure wearing a
compliant-looking list.