Reproducibility check runbook¶
Workflow: .github/workflows/reproducibility-check.yml
Status: Burn-in (non-blocking). Promotion criteria below. This
header is maintained by hand — no check compares it against the
workflow, so the promotion and demotion checklists each carry a step updating
it.
What this workflow does¶
On its weekly Friday cron (and on workflow_dispatch), the workflow:
- Builds
.#linpeasand.#linpeas-imagetwice on independentubuntu-latestrunners. - Records five values per build: linpeas store path, linpeas NAR hash, image store path, image tar SHA-256, and image manifest digest.
- Compares the three hash values pairwise (
linpeas_nar_hash,image_tar_sha256,image_manifest_digest); the two store paths are reported for context only and do not affect the result. - On any nonzero compare result — a hash mismatch or an exit-2 measurement failure: runs
diffoscopeon both pairs (the image tars and the linpeas tarballs), each under its own 20-minute cap.summary.txtand whichever of theimage.html/image.txtandlinpeas.html/linpeas.txtreports diffoscope wrote are uploaded as therepro-diffartifact (30-day retention), a GitHub issue labelledreproducibilityis opened, and thecomparejob is failed by itsfail job (on mismatch)step.
diffoscope is resolved from this repo's own flake — the compare job installs Nix through the ./.github/actions/setup-nix composite and invokes nix shell .#diffoscopeMinimal --command diffoscope. The version that runs therefore tracks flake.lock, not whatever a distribution archive currently ships, and the job reaches only the hosts already in its allowed-endpoints list.
How you'll be notified¶
During burn-in (continue-on-error: true on the compare job), workflow runs report green in the Actions UI even on mismatch — the primary alert channel is the auto-opened GitHub issue. If gh issue create itself fails, the step logs a WARN and the fallback signals are the compare job's own status and the repro-diff artifact.
To ensure mismatches are seen, the repo owner watches "All Activity" or "Issues" notifications on this repo.
The gh issue create invocation sets no --assignee; mismatches rely on default repo notification settings. Add --assignee to that invocation once a maintainer wants direct paging, and revisit after the first real mismatch (the "What to do when it fails" section below assumes the responder has already seen the issue).
What to do when it fails¶
- Open the auto-created issue (label:
reproducibility). - Follow the run link to the failed workflow run.
- Download the
repro-diffartifact. - Read
summary.txt, then open the.htmlreport of each pair it marksreport written. - Classify the divergence using the table below, then fix the root cause.
Bad-input failures (exit 2)¶
compare-repro.sh exits 2 when a hash field is absent, null, or malformed in either build's build.json — and likewise when it is called with the wrong argument count, jq is not on PATH, an input file is missing, or a payload is not a JSON object. None of those is a divergence — the measurement broke.
- A
nix path-info --jsonshape change is not the cause: bothmeasure hashessteps pipe it throughjq --exit-statusunderset -Eeuo pipefail, so a shape change fails its own build job, andcompareneeds both builds and never runs. - What can reach
compareis abuild.jsonthat uploaded or downloaded incompletely. - A
repro-diffartifact is present on exit 2 — the diffoscope and upload steps gate onexit_code != '0', which 2 satisfies (theforce_diffoscopeinput, below, widens the same gate) — but it diffs builds whose measurement the compare script already rejected. Read thebuild.jsonfiles in therepro-build-a/repro-build-bartifacts instead; those expire after 7 days (vsrepro-diff's 30), so exit-2 triage has a one-week window. - The
open issue (on mismatch)andfail job (on mismatch)steps gate on the sameexit_code != '0'condition, so exit 2 also files an issue and fails the job. The issue body names both causes. On a divergence the run's job summary holds a**Result:** MISMATCHline; on exit 2 thecompare hashesstep log holds the error instead. - If a build job itself went red, triage its
measure hashesstep there.
Exercising the diagnostic path on demand¶
The diffoscope step and the repro-diff upload run on a nonzero compare result, so while the compare keeps returning zero they are not exercised and a break in them stays latent until they are needed. workflow_dispatch carries a force_diffoscope input that runs them regardless:
The input widens the if: on the diagnostic steps only — the diffoscope run and the repro-diff upload. open issue (on mismatch) and fail job (on mismatch) keep the compare-nonzero condition unwidened by force_diffoscope, so a forced run can add execution but can never fabricate or suppress the reproducibility signal.
A healthy forced run on a reproducible tree looks like this:
setup-nixcompletes — no harden-runner block annotation, no fetch failure.- The diffoscope step runs both comparisons (the image tars, then the linpeas tarballs).
- The
repro-diffartifact uploads. - No issue is filed and the
comparejob does not fail.
Any of the first three missing means the diagnostic path is broken; a failing job or a filed issue means the forced run also found a genuine mismatch, which is triaged as above.
Reading summary.txt¶
The repro-diff artifact always carries summary.txt, one line per compared pair, naming that pair's diffoscope exit status and whether a report was written:
diffoscope exits 0 and writes no report when its two inputs are identical, so exit 0, report none on both lines is the expected outcome of a forced run on a reproducible tree — that is the diagnostic working, not failing. A diverging pair reports a non-zero exit with report written, and its image.html / linpeas.html are the files to open.
Common nondeterminism sources¶
| Symptom in diffoscope | Likely cause | Fix |
|---|---|---|
| Embedded timestamp differs | Build-time date leaking in |
Set SOURCE_DATE_EPOCH in derivation; use nix-store --query --references to find offender |
Go binary differs in .note.go.buildid |
-buildid flag default |
Pass -ldflags '-buildid=' |
| Rust binary differs in debug section | Source path leakage | Set RUSTFLAGS=--remap-path-prefix=... |
| Locale-dependent sort order in text output | LC_ALL unset |
Set LC_ALL=C in derivation |
Embedded $PWD / build directory |
nixpkgs stripAllList skipped |
Add to stripAllList or use removeReferencesTo |
| Parallel-build ordering in archives | tar without --sort=name |
Use tar --sort=name --owner=0 --group=0 --mtime='@${SOURCE_DATE_EPOCH}' |
Promotion criteria¶
Promote the compare job to a required check once all are true:
- 4 consecutive weekly runs in which the
comparejob'sfail job (on mismatch)step did not fire — workflow-level green is not the signal, sincecontinue-on-errormasks a mismatch - No outstanding
reproducibility-labelled issues - Image build time at parity with baseline (no diagnostic overhead left in workflow)
Promotion steps:
- Edit
.github/workflows/reproducibility-check.yml: removecontinue-on-error: truefrom thecomparejob. - Add a
pull_request:trigger targetingmainto the same workflow, with nopaths:/paths-ignore:filter (scripts/check-required-checks-no-paths.shrejects a path filter on any workflow listed indocs/security/required-checks.md, so it starts enforcing this once step 4 lands). The workflow currently runs only on its weekly cron andworkflow_dispatch; a required status context whose workflow never runs on pull requests leaves every PR waiting for a report that never arrives. - Push the branch, then dispatch against it:
gh workflow run reproducibility-check.yml --ref <promotion-branch>. The--refis load-bearing: a baregh workflow run reproducibility-check.ymltargetsmain, whose copy still carriescontinue-on-error: true, so it confirms nothing about the change. Confirm the run is green, then confirm the change itself by re-reading the dispatched ref's workflow file (gh api --header 'Accept: application/vnd.github.raw+json' repos/rvenutolo/linPEAS-flake/contents/.github/workflows/reproducibility-check.yml?ref=<promotion-branch>— without that header the endpoint returns base64 inside JSON): thecomparejob must carry nocontinue-on-error. There is no runtime signal on a clean tree — a matching build gives the flag nothing to mask. - Update the required-contexts table in
docs/security/required-checks.mdanddocs/_data/ci-check-categories.ymlin the same change, and runjust show-ci-summaryto regenerate the README summary block (the CI-summary freshness gate fails otherwise; see that page for the edit flow). - Update this runbook's Status header to
Required. - Add
compareto theprotect-mainruleset's required checks. Land other open PRs first — rebasing will not help them, sincemaincarries nopull_request:trigger for the workflow until this PR merges. Between the ruleset change and that merge, every open PR waits on acomparecontext that never reports. The promotion PR itself is unaffected: its own head carries the trigger. - Commit changes on a single PR titled
ci: promote reproducibility check to required.
Demotion criteria¶
If a real-world repro break cannot be fixed within one week of detection:
- Re-add
continue-on-error: trueto thecomparejob. - Drop
comparefrom theprotect-mainruleset's required checks first, as promotion added it first — that immediately stopscomparegating every open PR. - Remove
comparefrom the required-contexts table indocs/security/required-checks.mdanddocs/_data/ci-check-categories.yml, and runjust show-ci-summaryto regenerate the README summary block (the same gates that guard promotion fail otherwise). - Remove the
pull_request:trigger added at promotion — a demoted, non-required check has no reason to run on every PR. This rides in the same PR as the previous step; the ordering that matters is the ruleset change, not the order of edits inside one merge. - Update Status header to
Burn-in (demoted). Keep the date and the blocking issue number out of this file —check-ephemeral-refs.shblocks both shapes in tracked prose. They belong in the demoting commit message and in the tracking issue. - Note under a
## Active demotionssection which check is demoted and what has to be true to re-promote it, linking the issue by title. - Commit changes on a single PR titled
ci: demote reproducibility check from required, then confirm the demotion took: open a throwaway PR and check thatcompareno longer appears in its check list.