Skip to content

ci: prune third-party apt sources before installing build deps - #519

Merged
justin13888 merged 3 commits into
masterfrom
ci/517-apt-update-third-party-repo
Sep 10, 2026
Merged

justin13888 merged 3 commits into
masterfrom
ci/517-apt-update-third-party-repo

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every CI job whose first step is Install system build deps has been failing on every branch
since 2026-09-09, in that step, before a line of gamut is compiled:

Err:24 https://dl.google.com/linux/chrome-stable/deb stable/main amd64 Packages
E: Failed to fetch .../Packages.gz  Hash Sum mismatch
##[error]Process completed with exit code 100.

apt-get update exits 100 when any configured source fails. The failing source is the
Google Chrome apt repository preinstalled on the GitHub runner image — nothing this
repository configures. Because the step was sudo apt-get update && sudo apt-get install -y …,
that exit aborted the step, pkg-config/mise/sccache never installed, and every later step
died with sccache: command not found (exit 127).

This change makes the step depend only on the sources gamut actually consumes. Each of the six
jobs carrying the step (ci.yml: lint, coverage; extended.yml: benches, real-dng;
mutants.yml: incremental, full) now removes the apt sources this project does not use, then
runs apt-get update and apt-get install unmasked — no || true, no --allow-*, no
ignored exit code. A genuine failure of the Ubuntu archive, or of the packages themselves,
still fails the job exactly as before.

Ubuntu's own sources are recognised by content, not by filename, because the runner image
reaches the archive through a mirrorlist rather than a literal archive URL. From the failing
run's own log (image ubuntu24/20260907.300, Ubuntu 24.04 noble):

Get:1 file:/etc/apt/apt-mirrors.txt Mirrorlist [144 B]
Hit:2 http://azure.archive.ubuntu.com/ubuntu noble InRelease
Get:6 https://packages.microsoft.com/ubuntu/24.04/prod noble InRelease
Get:7 https://dl.google.com/linux/chrome-stable/deb stable InRelease

So the Ubuntu entries carry mirror+file:/etc/apt/apt-mirrors.txt, and a naive "keep anything
mentioning ubuntu.com" filter would have deleted them. The guard keeps any source file
matching the mirrorlist or an *.ubuntu.com host, in sources.list or sources.list.d
alike, and fails loudly if that leaves nothing rather than installing from a stale index.
Each kept/removed file is echoed, so every run records what it pruned.

Urgency and merge order

The outage was transient and has since resolved on its own. Runs from about 18:00 on
2026-09-09 are green repository-wide without this having merged — Google's index evidently
stopped serving the mismatched hash. So this pull request is now protection against
recurrence, not a blocker
, and there was room to get it right rather than land it fast.

That reframes, but does not remove, the reason to merge it: the step as written on master
still takes every job down whenever any third-party source the runner image ships has a bad
index, which is outside this project's control and has no warning. Should it recur before this
lands, branches recover with no code change of their own — re-run their failed jobs:

gh run rerun <run-id> --failed

or push any commit (including a merge of master), which starts a fresh run.

Review follow-up (commits 2 and 3)

6474ab2 was reviewed read-only: nothing Medium or above, and the reviewer confirmed by
simulation across 18 fixture layouts that the six blocks are byte-identical, that no trigger,
matrix, permission, concurrency, cache key, job name or step name changed, that the guard
precedes any apt-get, and that a symlink out of sources.list.d unlinks only the link. Three
Low findings were raised and are fixed here:

  • R1 — classify per source entry, not per file (ba3c223). grep -q … "$f" kept a whole
    file when any line matched, which failed in the direction that matters: a deb822 file
    holding an Ubuntu stanza and a Google Chrome stanza was kept intact, so the broken source
    survived and the outage was not fixed; a third-party list whose comment named an Ubuntu host
    was kept too. Classification now works an entry at a time — a deb/deb-src line, or a
    deb822 stanza keyed on its URIs: field — and rewrites a mixed file rather than keeping it
    whole. Comments no longer classify anything, and matching is case-folded. The safe direction
    is preserved: a file containing an Ubuntu entry is never deleted.
  • R2 — never conflate a read failure with "no match" (ba3c223). grep inside an if is
    exempt from errexit and its status 2 (I/O error) is falsy, so a source the script could not
    read
    was classified third-party and deleted. Sources are now read through sudo, awk's own
    failures are kept distinct from the "no Ubuntu entry here" signal (exit 10), and classification
    completes for every file before anything is modified — so an unreadable source aborts the step
    with nothing pruned.
  • R3 — neutralise the new failure mode (f60a984). The kept == 0 → exit 1 guard would
    convert a future runner-image change (a re-hosted archive, a renamed mirrorlist) from a working
    build into a total outage across all six jobs — a new way to break everything, introduced by a
    fix for something that breaks everything. If pruning would leave no Ubuntu source the step now
    prunes nothing, emits ::warning::, and proceeds. A classifier miss degrades to exactly the
    behaviour this step had before the guard existed, while an archive that is genuinely
    unreachable still fails loudly at the unmasked update/install.

Deferred to #518: three of the six copies of this block — extended.yml's two jobs and
mutants.yml's full-workspace job — cannot be exercised by any pull request, so drift
between the copies is invisible until a master or scheduled run. That is the concrete cost the
composite action in #518 buys down; a workflow-lint gate would also have caught R1 and R2.

Validation

Local, in the worktree:

  • python3 -c "yaml.safe_load(...)" over all three workflows — pass: each parses, and each
    still exposes exactly two apt-get steps under the same job and step names as before.
  • Simulation of the guard (extracted from ci.yml's lint step, /etc/apt rebound to a
    fixture tree, sudo/apt-get stubbed), run against both follow-up commits — pass,
    9/9 each
    :
    • runner image today (mirrorlist in sources.list, Chrome + Microsoft in sources.list.d) →
      keeps sources.list byte-for-byte, removes both;
    • deb822 sources.list.d/ubuntu.sources → kept; literal archive.ubuntu.com → kept;
    • R1a one deb822 file holding an Ubuntu and a Chrome stanza → file survives with the
      Chrome stanza gone and the Ubuntu stanza intact;
    • R1b third-party list whose comment names archive.ubuntu.com → removed anyway;
    • ports.ubuntu.com, security.ubuntu.com, deb-src, [arch=…] option blocks and a
      multi-stanza deb822 file → all survive;
    • uppercase HTTP://ARCHIVE.UBUNTU.COM → recognised as Ubuntu's;
    • R2 an unreadable (mode 000) Ubuntu source → step exits 1 with nothing pruned
      (verified that mode 000 does block reads in the harness environment, so this exercises the
      real path rather than a stub);
    • R3 no Ubuntu source recognised at all → stands down: nothing pruned, ::warning::
      emitted, apt-get update still runs, exit 0.
  • Block-identity check — pass: the six blocks hash identical after normalising the package
    list, and prune= precedes apt-get in every one.
  • mise run check-commitsno errors in 3 commitspass.

Not run, deliberately: mise run lint / mise run test / mise run fmt-check. This change
touches no Rust and nothing rustfmt sees.

The acceptance test is this pull request's own CI run, and it passed. The step can only get
past its first minute if pkg-config now installs, so a job that goes on to compile the
workspace is direct proof the outage is fixed. Observed on this branch at 6474ab2
(CI run 34386394665, Mutants run 34386394668):

Check Outcome Duration
Format & Metadata pass 35s
Clippy & Doctests pass 20m2s
Incremental (PR diff) 0 pass 2m27s-3m9s
Incremental (PR diff) 1 pass 2m27s-3m9s
Incremental (PR diff) 2 pass 2m27s-3m9s
Incremental (PR diff) 3 pass 2m27s-3m9s
Coverage (test gate) still running when this was written ~3h expected
Full workspace skipped by design (not a PR gate) -

These are the results for 6474ab2. The two review-follow-up commits were re-run and the
revised guard passes the same way, at f60a984 (CI run 34390387549, Mutants run
34390387313):

Check Outcome Duration
Format & Metadata pass 42s
Clippy & Doctests pass 18m17s
Incremental (PR diff) 0 pass 1m41s
Incremental (PR diff) 1 pass 2m7s
Incremental (PR diff) 2 pass 1m51s
Incremental (PR diff) 3 pass 1m45s
Coverage (test gate) still running when this was written ~3h expected
Full workspace skipped by design (not a PR gate) -

So the per-entry classifier, the read-failure handling and the stand-down all survive a real
runner: Clippy & Doctests again spends eighteen minutes compiling the workspace, which is only
reachable through a successful apt-get install behind the rewritten guard.

Six of the seven required checks are terminal passes. Before this change the same jobs died in
under a minute inside Install system build deps; Clippy & Doctests alone now spends twenty
minutes actually compiling the workspace, which is only reachable through a successful
apt-get install. Coverage (test gate) runs the instrumented workspace build and legitimately
takes about three hours (it ran 2h59m on #485 before the outage), so it had not reported yet; it
shares the identical step with the jobs that already passed, so it is not a blocker for this
claim.

Risks and rollout

  • Sources removed on the runner are Chrome/Microsoft (and any other non-Ubuntu source the image
    ships). Nothing in these jobs apt-get installs from them; every other tool arrives via mise,
    dtolnay/rust-toolchain, mozilla-actions/sccache-action, or a vendored submodule. The
    runner VM is ephemeral, so the removal does not outlive the job.
  • If a future image renames its mirrorlist and stops using *.ubuntu.com hosts, the guard
    fails the step with an explicit message. That is the deliberate trade: a loud failure beats a
    silent install from a stale index.
  • Rollback is a revert of the single commit, restoring
    sudo apt-get update && sudo apt-get install -y ….
  • Approach 3 from the issue triage (source pkg-config from mise instead of apt) was not taken:
    mise.toml still records that pkg-config "lacks a clean mise backend", and nothing found
    here overturns that.
  • The 25-line guard is duplicated across the six steps because GitHub Actions has no YAML
    anchors and a composite action would live under .github/actions/, outside this change's
    manifest.

Issue

Closes #517.

Decisions taken

Issue 517 - CI: every job fails in "Install system build deps" when apt-get update hits a broken third-party repo
Plan:     v1
Branch:   ci/517-apt-update-third-party-repo
Base:     origin/master (6a75ec4)
Cause:    the runner image's preinstalled Google Chrome apt source serves a Packages.gz whose hash does not match its index, so `apt-get update` exits 100; the step's `&&` then prevents pkg-config from installing and every later step fails on the missing sccache. Verified from run 34385470688 and reproduced by re-running the failed jobs.
Touches:  .github/workflows/*.yml - only the "Install system build deps" steps
Will not: mask a failure with `|| true` or an ignored exit code; change any other step, job, trigger or matrix; touch repository code
Lane:     parallel, and blocking - every other open PR is red until this lands
Settled:  the run's S5 (no workflow edits) is lifted for this entry only

Decisions taken.
1. Deliverable boundary
   Taken:    repair the step in every workflow job that carries it, and nothing else; the fix is the whole issue
   Rejected: pinning a runner image version - moves the failure rather than removing it, and pins the repository to an image that will age out
   Reverses: revert the workflow edit
   Filed:    -
2. Mechanism
   Taken:    remove every apt source the image ships that is not Ubuntu's own, then run `apt-get update` and `apt-get install` unmasked. Sources are classified by content, not filename: a file is kept if it references the image's mirrorlist (`apt-mirrors.txt`) or an `*.ubuntu.com` host, scanning `/etc/apt/sources.list` and `/etc/apt/sources.list.d/*.{list,sources}` alike, so the guard is correct whether the distribution's entries live in the one-line file or a deb822 file. If pruning leaves no Ubuntu source the step exits 1 with an explicit error rather than installing from a stale index.
             Evidence for this image: the failing job's log (run 34385470688, job 102580512606, image `ubuntu24/20260907.300`) shows `Get:1 file:/etc/apt/apt-mirrors.txt Mirrorlist` followed by `azure.archive.ubuntu.com` hits, plus exactly two third-party sources, `packages.microsoft.com` and the failing `dl.google.com/linux/chrome-stable`. The script was then run locally against four simulated `/etc/apt` layouts (this image; deb822 `ubuntu.sources`; literal `archive.ubuntu.com`; no Ubuntu source at all) and behaved correctly in all four, failing loudly only in the last.
   Rejected: (a) `-o Dir::Etc::sourcelist=/etc/apt/sources.list -o Dir::Etc::sourceparts=-` - the orchestrator's own caveat applies and the log cannot prove where noble's entries live on this image; if they are in `sources.list.d` this silently updates nothing and installs from a stale index. The content-based prune subsumes it without that risk.
             (b) sourcing pkg-config from mise - `mise.toml` lines 10-11 still state that pkg-config "lacks a clean mise backend"; no clean backend was found, and the entry says not to force it.
             (c) `sudo apt-get update || true` (the shape the issue body proposes) - forbidden by this run's decision authority: it masks the exit code of a check.
             (d) deleting `/etc/apt/sources.list.d/*` wholesale - would delete the distribution's own sources under the deb822 layout.
             (e) pinning the runner image, as in decision 1.
   Reverses: restore the original `sudo apt-get update && sudo apt-get install -y pkg-config`
3. Acceptance
   Taken:    the fix proves itself - this PR's own required checks must go green, which is only possible if the step now installs pkg-config successfully; that is the acceptance test and no local gate can substitute for it

Appended by the lane.
4. Where the guard's own test lives
   Taken:    the four-layout simulation of the pruning script was run from the lane's scratch directory and its outcome recorded here, not committed
   Rejected: committing a shell/YAML test - the manifest is `.github/workflows/*.yml` only, and the repository has no gate (no actionlint, no yamllint, no shell test tier) that would run such a file; adding one is separate work, filed rather than smuggled in
   Reverses: delete nothing; the harness is disposable and its inputs are quoted above

5. Alternatives reconsidered on review (Q1, Q2, Q4, Q5) - decided by the orchestrator on review
   Taken:    keep the prune. It is more precise than the alternatives, it is already proven by a
             full green run, and with decision 6 applied it no longer carries a worse failure
             mode than they do. Q4 is answered by decision 7. Q5 - a composite action removing
             the six-way duplication - is deferred to the already-filed #518.
   Rejected: issue #517's own suggested shape, `sudo apt-get update || true`, letting the install
             be the gate. It was considered rather than ignored, and not taken: it masks the exit
             code of a check, which this run's decision authority forbids. The issue text is
             untrusted evidence, not a mandate.
             Also `-o Dir::Etc::sourcelist=... -o Dir::Etc::sourceparts=-`, which remains
             unverifiable-safe for the reason established here: noble reaches the archive through
             `mirror+file:/etc/apt/apt-mirrors.txt`, so nobody can prove from the log alone where
             its entries live, and if they are under `sources.list.d` it updates nothing.
   Reverses: replace the guard with either rejected shape.
6. Failure mode when classification recognises no Ubuntu source (review finding R3)
   Taken:    prune nothing, emit `::warning::`, and proceed to the unmasked `update`/`install`
   Rejected: the `exit 1` this branch shipped in 6474ab2 - it converts a future runner-image
             change into a total CI outage across all six jobs, which is a new instance of the
             very failure the change exists to prevent. Standing down degrades a classifier miss
             to today's behaviour instead, and an unreachable archive still fails loudly.
   Reverses: restore the `exit 1`.
7. Classifier correctness (review findings R1 and R2)
   Taken:    classify one source entry at a time (a `deb`/`deb-src` line, or a deb822 stanza
             keyed on `URIs:`), rewriting a file that mixes Ubuntu and third-party entries;
             read through `sudo` and keep awk's failures distinct from "no Ubuntu entry here",
             completing classification for every file before modifying any of them
   Rejected: per-file `grep -q`, which kept a mixed deb822 file whole (so the broken source
             survived), let a comment rescue a third-party list, and - because grep's status 2 is
             falsy inside `if` - deleted a source it merely could not read.
   Reverses: restore the single `grep -Eq … "$f"` classification.

Unresolved review notes

None.

The runner image ships Google Chrome and Microsoft apt sources this
project does not consume. `apt-get update` exits 100 when any source
fails, so once Chrome's index began serving a Packages.gz whose hash did
not match, every job died in its first step: pkg-config, mise and sccache
never installed and every later step failed with exit 127.

Drop the sources this project does not use, then update and install
unmasked, so a real failure of the Ubuntu archive or of the packages
themselves still fails the job. Ubuntu's own entries are matched by
content rather than by filename: the image reaches the archive through
the mirrorlist mirror+file:/etc/apt/apt-mirrors.txt, and which file holds
them has moved between sources.list and sources.list.d across releases.
When nothing matches, the step fails loudly instead of installing from a
stale index.

Refs #517
The guard kept a whole source file when any line in it matched, which
fails in the direction that matters: a deb822 file holding an Ubuntu
stanza and a Google Chrome stanza was kept intact, so the broken source
survived and the outage was not fixed. A third-party list whose comment
merely named an Ubuntu host was kept for the same reason.

Classify a source entry at a time instead -- a deb/deb-src line, or a
deb822 stanza keyed on its URIs field -- and rewrite a file that carries
both rather than keeping it whole. Comments no longer classify anything.
Matching is case-folded, so an uppercase URI is still recognised.

Read the files through sudo and keep awk's own failures distinct from
"this file has no Ubuntu entry": a grep that cannot read a file exits 2,
which is falsy, so the previous shape silently classified an unreadable
Ubuntu source as third-party and deleted it. Classification now completes
for every file before anything is modified, so a file that cannot be read
aborts the step with nothing pruned.

Removal still unlinks the path itself, so a symlink out of sources.list.d
loses only the link.

Refs #517
Refusing to install when classification recognised no Ubuntu source was
meant to stop the step from working off a stale index, but it introduced
a new way to break everything: a re-hosted archive, a renamed mirrorlist
or any other runner-image change the classifier does not know would turn
a working build into a total outage across all six jobs -- the same
failure this guard exists to prevent.

Prune nothing in that case and carry on instead, emitting a warning that
classification stood down. A classifier miss then degrades to exactly the
behaviour this step had before the guard existed, rather than to an
outage, and the property the exit was protecting is kept anyway: an
archive that is genuinely unreachable, or a package that cannot be
installed, still fails loudly at the unmasked update and install.

Refs #517
@justin13888
justin13888 merged commit eb502cd into master Sep 10, 2026
8 checks passed
@justin13888
justin13888 deleted the ci/517-apt-update-third-party-repo branch September 10, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: every job fails in "Install system build deps" when apt-get update hits a broken third-party repo

1 participant