Renovate nix PRs: clear titles and a package-change body (RIG-2327)
Problem / Intent
Section titled “Problem / Intent”- A Renovate PR that moves a nix lock shows only a rev move. Compass #580 has the title
chore(deps): update cachix/devenv-nixpkgs digest to c2f38fe. Its body is Renovate’s table row| cachix/devenv-nixpkgs | digest | \c946ff3` → `c2f38fe` |` plus the stock Configuration block. - That bump moved protoc-gen-go from 1.36.11 to 1.36.12 and Chromium from 150 to 153. Neither the title nor the body says so. A reviewer has to build both shells by hand to learn what changes.
- The title also does not say which shell moves. The root channel and the agent-image channel have near-identical titles.
Intent: every Renovate PR that moves a nix-built tool gets a CI-written ## Packages that change section. It leads with the direct packages we use (name: old → new) and puts the full transitive closure diff below that, collapsed. The title names the thing that moves, for example nixpkgs channel (dev shell). Matt ruled option B plus clearer titles on RIG-2327 (2026-10-10).
Evidence (verified this session)
Section titled “Evidence (verified this session)”Renovate is pinned at bunx renovate@44.46.2 (.github/workflows/renovate.yml). These quotes are from that npm tarball (dist/):
hashBody(modules/platform/pr-body.js) cuts the body at a Reviewable marker before hashing:const reviewableRegex = regEx(/\s*<!-- Reviewable:start -->/);andif (reviewableIndex > -1) result = result.slice(0, reviewableIndex);.ensurePr(workers/repository/update/pr/index.js) leaves the PR alone only whenexistingPrBodyHash === newPrBodyHash(and title, base and labels match). Otherwise it sends the whole new body:await platform.updatePr(updatePrConfig);.- The
repositoryCacheoption (config/options/index.js) hasdefault: "disabled", and neithertools/renovate/bot-config.json5norrenovate.ymlsets it. SoensurePrcompares body hashes on every run. [INFERENCE: the null-cache code path was not read.] getPrBody(workers/repository/update/pr/body/index.js) builds the body only from Renovate’s owncontentobject:prBody = compile(prBodyTemplate, content, false);. No input reads the live PR body or a CI result.- The
groupoption hascommitMessageTopic: "{{{groupName}}}", butgenerate.js(workers/repository/updates/) applies it to a one-dep branch only on opt-in:const useGroupSettings = hasGroupName && (groupEligible || singleUpdateGroup && branchUpgrades[0].groupSingleUpdates === true);. So thedigestdefault wins:commitMessageTopic: "{{{depName}}} digest". That is #580’s title. flatten.js(workers/repository/updates/) merges thedigestobject, then re-applies packageRules:updateConfig = mergeChildConfig(updateConfig, updateConfig[updateConfig.updateType]);thenapplyPackageRules(updateConfig, "update-type-merge"). A packageRulecommitMessageTopictherefore wins.compileCommitMessage(generate.js) lowercases the title line:splitMessage[0] = splitMessage[0].toLowerCase();.modules/platform/github/index.jshasconst GitHubMaxPrBodyLen = 58e3;. GitHub itself rejects a body over 65536 characters.
GitHub docs (“Events that trigger workflows”, “REST API endpoints for pull requests”):
pull_request_target: “This event runs in the context of the default branch of the base repository, rather than in the context of the merge commit, as thepull_requestevent does.” So a PR controls the YAML of apull_requestworkflow. The same section warns: “Avoid using this event if you need to build or run code from the pull request.”workflow_run: “The workflow started by theworkflow_runevent is able to access secrets and write tokens, even if the previous workflow was not.” Also: “This event will only trigger a workflow run if the workflow file exists on the default branch.” And: “A workflow run is triggered regardless of the conclusion of the previous workflow.”- “Update a pull request” (
PATCH /repos/{owner}/{repo}/pulls/{pull_number}) takes onlytitle,body,state,baseandmaintainer_can_modify. None is a precondition, so a body write cannot be conditional. pr-base-repoint.ymlquotes the loop guard: “an event created with the defaultGITHUB_TOKENtriggers no new workflow run (the sole exceptions areworkflow_dispatchandrepository_dispatch)”.- A skipped required check stays Pending (“checks … will remain in a “Pending” state“).
devenv fork, at the rev devenv.lock pins:
Devenv::eval(devenv/src/devenv/mod.rs):let full_attr = format!("devenv.config.{attr}");thenresults.insert(attr.clone(), value);. The output is{ "<attr>": <value> }.Devenv::build(same file) builds.map(|a| format!("devenv.config.{a}")).Devenv::container_build(devenv/src/devenv/container.rs):let attr = format!("devenv.perSystem.{target_system}.containerBuilds.{name}.derivation");.main.rsprints the path:Ok(CommandResult::Print(format!("{path}\n"))).mkContainerBuilds(devenv-nix-backend/bootstrap/bootstrapLib.nix) setscontainer.isBuilding = lib.mkForce true;.agent-image/devenv.nixreacts:// lib.optionalAttrs config.container.isBuilding {.bootstrapLib.nixloads a local module:localPath = devenv_root + "/devenv.local.nix";andlib.optional (builtins.pathExists localPath) localPath..gitignorelistsdevenv.local.nix.
Repo facts and probes:
ci.ymlhaspermissions: contents: readandtypes: [opened, synchronize, reopened].pr-base-repoint.ymlsays aneditedrun “self-skipped and double-listed every check on the PR”. The Renovate App token lives in themainenvironment, which a PR run cannot enter.devenv.nixhas++ lib.optionals pkgs.stdenv.isLinux [ pkgs.chromium ]inpackages. Outsidepackagesit hasenv(E2E_FONTCONFIG_FILE),processes.nats.exec(exec ${lib.getExe pkgs.nats-server}) andservices.postgres.agent-image/devenv.nixhaspackages = [ ];andcopyToRoot = [ toolchain ];incontainers.agent.layers. On the pinned channel,map (p: p.name) (buildEnv { paths = [ hello jq ]; }).pathsgives["hello-2.12.3","jq-1.8.2"].tools/toolchain/toolchain-tools.nixhasbunPin = import ./versions/bun.nix;.agent-image/toolchain.nixhasbun = (import ../tools/toolchain/toolchain-tools.nix { inherit pkgs; }).bun;.nix path-info -r --json --json-format 1 <path>prints one object keyed by store path (nix 2.34.8).builtins.parseDrvName "protoc-gen-go-1.36.12"gives{"name":"protoc-gen-go","version":"1.36.12"}.
Approach
Section titled “Approach”Recommendation: an unprivileged pull_request workflow that collects the package diff as data, a workflow_run workflow from the default branch that renders and writes it after a Reviewable marker, and a commitMessageTopic on each nix-lock rule.
-
Two workflows, neither in
ci.yml, neither required.renovate-nix-package-diff.yml(collect) runs onpull_requestwith read-only permissions. It runs head-chosen code and uploads a JSON artifact.renovate-nix-package-diff-publish.yml(publish) runs onworkflow_run(completed) of collect. Its YAML and code come from the default branch, and only it holdspull-requests: write.
-
Collect trigger.
types: [opened, synchronize, reopened, edited]. Collect runs only whengithub.head_refstarts withrenovate/and the head repo is this repo. These guards save cost only; a PR can edit them. Every other PR’seditedmakes a skipped run, the costpr-base-repoint.ymlalready pays. -
Targets are keyed by changed file, not by Renovate rule. This stays correct when RIG-5045 merges the devenv fork rules.
Label Lock Other triggers Closure root dev shell devenv.locktools/toolchain/versions/*.nixdevenv build packageDiff.closureagent image agent-image/devenv.locktools/toolchain/versions/bun.nixdevenv container build agent -
A CI-only module. Collect copies
tools/renovate/nix-package-diff.local.nixtodevenv.local.nixin each target dir, on both sides. It declares one option,packageDiff:direct: the names ofconfig.packages, plus the.pathsnames of everycopyToRootentry inconfig.containers.*.layers.closure: apkgs.writeTextoverbuiltins.toJSONof the profile,config.envand each process’sexec.
This is the one new abstraction. No existing attr exposes the toolchain inputs or a whole-shell closure root, and the local-module hook needs no tracked change to either
devenv.nix. -
Direct list (eval only).
devenv eval packageDiff.directruns at the merge base and at the head, with each side’s lock-pinned devenv (thedevenv-cli --mode flakerefpattern). The core splits each name with theparseDrvNamerule and diffs the versions. -
Transitive block (built). Per target, collect builds the head root and writes
nix path-info -r --json --json-format 1to a file. It deletes that root, runsnix store gc, then does the same for the base. Bootstrap tools and both devenv CLIs are rooted with--out-linkfirst. Peak disk is one target closure. -
The artifact is data. Collect uploads
diff.json: the head SHA and, per target, a label plus name/version lists or an error code. It carries no Markdown. -
Publish treats the artifact as untrusted. Before any write it checks:
workflow_run.eventispull_requestandworkflow_run.pull_requestsnames exactly one open PR from this repo whose head ref starts withrenovate/;- the artifact’s head SHA equals
workflow_run.head_shaand the PR’s currenthead.sha; - the artifact is at most 1 MiB, every label is a known target label, every name and version matches
^[A-Za-z0-9._+-]{1,128}$, and every error is a known code.
It then renders the section with default-branch code, so the marker, heading and links are its own.
-
Body write. The section starts with
<!-- Reviewable:start -->, then<!-- nix-package-diff head=<sha> -->. Renovate’s hash ignores everything after the marker, so Renovate never rewrites the section. When Renovate rewrites its own region, the tail is dropped andeditedfires. Collect restores the data from cache. -
The write is best-effort. GitHub has no conditional PATCH (Evidence). Publishers are serialized per PR by a concurrency group. Publish re-reads the body right before the PATCH, keeps everything before the marker as read, replaces only the tail, and sends only
body.- Residual race: an edit that lands between that GET and the PATCH is lost. The window is one API round trip.
- Renovate’s region heals: its next run sees a hash mismatch and rewrites, and the section returns.
- A human edit in that window, such as ticking the rebase checkbox, must be redone.
- No loop: publish PATCHes with
GITHUB_TOKEN, which starts no new run.
-
Titles. Each packageRule for a dep of a
devenv.lockoragent-image/devenv.lockmanager sets acommitMessageTopicnaming what moves and the target label. #580’s title becomeschore(deps): update nixpkgs channel (dev shell) to c2f38fe.
prBodyNotes and prBodyTemplate cannot carry the list or a placeholder. They compile only from Renovate’s own config (Evidence, getPrBody), and a placeholder in Renovate’s hashed region would be reverted on the next run.
Alternatives considered
Section titled “Alternatives considered”- Closure build vs eval-only (decided: hybrid, within ruling B). Eval-only drops transitive moves. Closure-only buries protoc-gen-go and chromium among many libraries. The hybrid leads with the eval-only direct list, which is what Matt asked for, and collapses the closure diff. Truncation applies to the closure block only.
- Where the write token lives (decided: separate
workflow_runworkflow). Two jobs in onepull_requestworkflow are not enough: the PR controls that YAML, so it could add steps to the job holding the token.pull_request_targetgives default-branch YAML, but its docs warn against building PR code there.workflow_runkeeps the build unprivileged and the write in default-branch code. - Rendered Markdown as the artifact. Publish would have to sanitize free text. JSON with a closed character set is simpler to validate. Rejected.
nix store diff-closurestext. It has no JSON mode and needs both closures in the store at once. Rejected forpath-infoJSON.devenv build containers.agent.derivationordevenv.profileas roots. The first skipsisBuilding, so it is not the shipped image. The second omitsenvand process paths. Rejected.- Own markers inside Renovate’s hashed region. Renovate would rewrite the body on every run. Rejected.
- A job in
ci.yml, the App token, apaths:filter.ci.ymlomitseditedon purpose. The App token cannot leavemain. The in-job file check already coverspaths:. All rejected.
Global Constraints
Section titled “Global Constraints”- Renovate
renovate@44.46.2. CI nix iscachix/install-nix-actionv31 (nix 2.35.2), at the SHAci.ymlpins. - Both workflows copy
ci.yml’sinstall-nix-actionextra_nix_configblock (neveraccept-flake-config) and its phase-one “Put the language toolchains on PATH” step. Collect changes--no-linkto--out-link "$RUNNER_TEMP/langs"sonix store gckeeps bun. - Collect permissions:
contents: read,pull-requests: read. Publish permissions:actions: read,contents: read,pull-requests: write. Every checkout setspersist-credentials: false. - Publish checks out only the default branch at
github.sha. It downloads the artifact withrun-id: ${{ github.event.workflow_run.id }}andgithub-token: ${{ github.token }}into${{ runner.temp }}, never the workspace, and never executes artifact content.actions/download-artifactreads only the current run unless it gets that token. actions/cache/restore,actions/cache/save,actions/upload-artifactandactions/download-artifactat the SHAsci.ymlandrelease.ymlalready pin.- Concurrency, both
cancel-in-progress: false: collectnix-package-diff-<PR number>; publishnix-package-diff-publish-<workflow_run.head_branch>, which exists even whenworkflow_run.pull_requestsis empty. - Advisory only. Never add either workflow to the required checks or to
ci.yml’srollup. - The section’s first line is exactly
<!-- Reviewable:start -->. The PATCHed body is at most 65536 characters. Only the<details>block truncates, and it says how many lines it dropped. - Code shape: pure core
tools/renovate/nix-package-diff.core.ts(no I/O, noprocess, noBun) withnix-package-diff.core.test.ts. Thin I/O shelltools/renovate/nix-package-diff.tswithcollectandpublishsubcommands. TypeScript run by bun; bash only for one-liners. - GitHub calls use
gh api(thedesign-ledger-gate/index.tsidiom). The body PATCH passes the body by file (-F body=@<file>), never argv. - Comments are 1–2 lines, 4 at most, with no issue IDs.
- Lint:
biome checkon touched TS and JSON;rumdl checkon touched Markdown. - Compass only. A port to other repos is a follow-up.
T0: Measure the unknowns (no code)
Section titled “T0: Measure the unknowns (no code)”Run on a branch against #580’s two revs (c946ff3, c2f38fe), with a draft devenv.local.nix:
devenv eval packageDiff.directat each rev. Expected: protoc-gen-go and chromium differ. This also confirms the fork loads an untrackeddevenv.local.nix.devenv build packageDiff.closureanddevenv container build agentat each rev. Record wall time and peak disk with gc between sides.- Check that the agent closure contains no path named
devenv-profile. If one is there, the root is wrong. - Capture both
path-infooutputs as test fixtures, unedited. - Confirm that a Renovate App body edit fires
pull_request.edited, and that aworkflow_runrun of a draft publisher sees the PR inworkflow_run.pull_requests.
Interfaces: produces fixtures for T1 and the timeout-minutes figure for T3.
T1: Core module
Section titled “T1: Core module”Pure functions in tools/renovate/nix-package-diff.core.ts. Tests are table-driven from T0’s fixtures and #580, plus hostile artifacts for parseArtifact.
Interfaces:
export interface ClosureTarget { label: string; dir: string; lock: string; triggers: readonly string[]; closure: { kind: "build"; attr: string } | { kind: "container"; name: string };}export const CLOSURE_TARGETS: readonly ClosureTarget[];export function targetsFor(changedFiles: readonly string[]): ClosureTarget[];
export interface NameVersion { name: string; version: string;}export function splitDrvName(storeName: string): NameVersion;export function parseDevenvEval(json: string, attr: string): string[];export function parsePathInfo(json: string): string[];
export interface PackageChange { name: string; from: string[]; to: string[];}export function diffVersions( base: readonly NameVersion[], head: readonly NameVersion[],): PackageChange[];
export const ERROR_CODES: readonly ["eval-failed", "build-failed", "path-info-failed"];export type TargetResult = | { label: string; direct: PackageChange[]; transitive: PackageChange[] } | { label: string; error: (typeof ERROR_CODES)[number] };export interface DiffArtifact { headSha: string; skip: boolean; results: TargetResult[];}export const MAX_ARTIFACT_BYTES = 1_048_576;export function parseArtifact(raw: string): DiffArtifact;
export const REVIEWABLE_MARKER = "<!-- Reviewable:start -->";export const REVIEWABLE_CUT_VERIFIED_AT = "44.46.2";export function renderSection(artifact: DiffArtifact, maxChars: number, runUrl: string): string;export function sectionHeadSha(body: string): string | undefined;export function upsertSection(body: string, section: string): string;splitDrvNamedrops the/nix/store/<hash>-prefix. The version starts at the first-followed by a non-letter. A trailing output suffix (-bin,-dev,-lib,-out,-man,-doc) is removed from the version.diffVersionskeeps a name only when its version sets differ and one side has a non-empty version.parseArtifactthrows on anything item 8 of Approach rejects: size, shape, a non-hex or wrong-lengthheadSha, an unknown label or error code, or a name or version outside the character set.renderSectionprints, per target, the direct list, then<details><summary>Transitive closure: N changes</summary>. An empty list prints “No package version changes.” An error prints “Diff unavailable (<code>), see<runUrl>.”upsertSectionkeeps the text beforeREVIEWABLE_MARKERas given, drops the rest, and appends the section.
T2: CI-only module and collect
Section titled “T2: CI-only module and collect”- Add
tools/renovate/nix-package-diff.local.nix(item 4 of Approach). nix-package-diff.ts collectreadsPR_NUMBER,HEAD_SHAandGH_REPO. It lists the changed files, pickstargetsFor, and adds agit worktreeat the merge base.- For each target and side, it copies the module in, resolves devenv with
devenv-cli --mode flakeref, roots that CLI withnix build --out-link, and runs the eval, the closure build andpath-info(Approach items 5 and 6). - It writes
diff.json. It exits non-zero after writing if any target errored.
Interfaces: consumes T1. Produces diff.json (a DiffArtifact).
T3: Workflows and publish
Section titled “T3: Workflows and publish”renovate-nix-package-diff.yml (collect), one job:
- Skip check, before nix: a
gh pr viewone-liner. If the body hashead=<HEAD_SHA>, write{"headSha":"<sha>","skip":true,"results":[]}and go to step 4. actions/cache/restorewith keynix-package-diff-v1-<head sha>.- On a miss: checkout of the head (
fetch-depth: 0), install-nix, phase one,collect,actions/cache/save. - Upload
diff.jsonas thenix-package-diffartifact, even whencollectfailed.
renovate-nix-package-diff-publish.yml (publish), one job. It runs on workflow_run: { workflows: [renovate-nix-package-diff], types: [completed] }. Its job-level if: requires github.event.workflow_run.event == 'pull_request', a conclusion of success or failure, startsWith(github.event.workflow_run.head_branch, 'renovate/'), workflow_run.head_repository.full_name == github.repository, and workflow_run.pull_requests of length exactly 1. Other PRs skip collect, so their runs carry no artifact and must never reach the download. Steps: a gh pr view guard that re-checks the one PR is open, same-repo and renovate/ (exit 0 otherwise), checkout of the default branch, install-nix, phase one, download the artifact, then nix-package-diff.ts publish. publish reads ARTIFACT_FILE, RUN_HEAD_SHA, PR_NUMBERS (JSON of workflow_run.pull_requests[*].number), RUN_URL and GH_REPO.
- Read the PR and run the checks in Approach item 8, then
parseArtifact. On a failed check it exits non-zero with no write. Onskipit exits 0. - Render. Re-read the body and head SHA. Exit 0 if the head moved.
- Upsert into that fresh body, and PATCH
bodyonly if it changed.
Interfaces: consumes T1 and T2. Produces the PR body section, a collect check on the PR, and a publish run in Actions.
T4: Titles
Section titled “T4: Titles”In tools/renovate/config.json5, add commitMessageTopic to each packageRule whose dep comes from a devenv.lock or agent-image/devenv.lock manager:
| matchDepNames | commitMessageTopic |
|---|---|
cachix/devenv-nixpkgs |
nixpkgs channel (dev shell) |
cachix/devenv-nixpkgs-agent-image |
nixpkgs channel (agent image) |
RigelBuild/meissa |
meissa lint toolchain (dev shell) |
RigelBuild/devenv, RigelBuild/devenv-agent-image |
devenv modules (dev shell and agent image) |
The last row is the one devenv fork rule that RIG-5045 lands (matchDepNames lists both, groupName: "devenv fork"). That rule sets commitMessageTopic: "devenv modules (dev shell and agent image)" and the same value in group: { commitMessageTopic }.
config.test.ts gains two checks:
- For every custom manager whose
managerFilePatternsmatch aCLOSURE_TARGETSlock, a packageRule matching itsdepNameTemplatesets acommitMessageTopiccontaining that target’slabel. When the rule’sgroupNamecovers more than one such dep,group.commitMessageTopicmust be set and contain the label. Managers of other trigger files, such astools/toolchain/versions/bun.nix, are out of scope. - The
bunx renovate@pin inrenovate.ymlequalsREVIEWABLE_CUT_VERIFIED_AT. The failure message says to re-readhashBodyfor the marker cut, then bump the constant.
Interfaces: consumes CLOSURE_TARGETS and REVIEWABLE_CUT_VERIFIED_AT from T1. The titles take effect on Renovate’s next run, which retitles open PRs.
- T0: Measure eval, closures, disk, the container root,
editedandworkflow_run(no code) - T1: Core module, fixture tests and hostile-artifact tests
- T2: CI-only module and
collect - T3: Collect and publish workflows, and
publish - T4:
commitMessageTopicper lock rule and the twoconfig.test.tschecks
Resolved decisions (Matt, 2026-10-10)
Section titled “Resolved decisions (Matt, 2026-10-10)”- Option B on RIG-2327: a CI job diffs the closures into the PR body, and titles get clearer. Recorded as DL-447.
Open Questions
Section titled “Open Questions”- The
REVIEWABLE_CUT_VERIFIED_ATcheck turns every Renovate self-bump PR red until someone re-readshashBody. That is intended. If it proves too noisy, the fallback is a self-pin PR checklist item. - Reusing Reviewable’s marker borrows its convention. If a Reviewable integration is ever installed, the two tails would collide. Accepted for now.