fix(prune): resolve each file's real component; never abort mid-rotation #37
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/prune-component-resolution"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A real rotation would have aborted partway through
Hardcoded
SOURCE_COMPONENT = "main"when building the download/delete coordinate — but publishes span two components.publish.ymlsplits the-dbgpackage intodebugand everything else intomain. Verified across every manifest inreleases/:{'main': 79, 'debug': 12}.So the
-dbgfile'smaincoordinate 404s,download_fileraisedSystemExit, and because the rotation was a plain list comprehension that exception killed the whole run — after deleting frommainand before the Fastly purge. That leavesmain'sPackagesindex advertising pool files that no longer exist, and apt 404ing mid-install for users. Exactly the state the code's own comments say the purge exists to prevent.Fixes
releases/*/manifest.json'spublishes[]—publish.py's own authoritative record of where it put each file. Falls back to the naming rule mirroringpublish.yml(*-dbg_* → debug) for files published before manifests recorded it, or when a manifest is unreadable — a malformed manifest must not abort a rotation either.SystemExit; each is caught, recorded, and the loop continues. Critically: never delete when the archive step failed, so a file is never removed frommainwithout a copy inarchive.download_filewrote whatever arrived and checked nothing — a connection dropped at 180 MB of a 227 MB image produced a truncated file thatupload_debwould faithfully store (Content-Lengthcomes fromos.stat, so the request is self-consistent), and then the good copy inmainwas deleted. Now compares the downloaded size against what the registry reports.archive. Previously a hardcoded(main, archive)pair, leavingdebug's index stale after a-dbgdeletion — and the purge was skipped entirely on an abort, which is the worst available outcome. Exits non-zero afterwards with a per-file summary.--keep < 1.0would archive the entire history of every line not inconfigs/lines.toml— and7.0is no longer registered, so a0typed into a dispatch box would sweep eight releases out ofmain. Per-linekeep_in_main = 0stays the sunset knob: it lives in a committed file that gets read in review, not a workflow input box.prune.ymlbuilt its argv by string concatenation and relied on word-splitting, sokeep="3 --owner someoneelse"ordistribution="trixie --keep 0"injected flags into a destructive tool. Now validated and passed as an array. The# shellcheck disable=SC2086that sat there silenced the warning about exactly this — and never did anything anyway, since shellcheck doesn't lint workflow YAML.Verification
Component resolution against the real manifests: 91 files indexed,
{'main': 79, 'debug': 12}; a-dbgfile resolves todebug, an image file tomain; the fallback rule handles unknown files in both directions.Safety properties, with the HTTP layer stubbed:
['debug', 'main']— both, correctly--keepfloor:3and1accepted;0,-1,abcallSystemExit(2)with the message pointing atkeep_in_mainfor a real sunset.Tests
select_versions_to_archivehad none, despite being the function that decides what gets deleted. Added doctests matching the conventionparse_versionalready used: newest-N retention,.P-rebuild handling, per-line independence (7.1 churn must not evict 7.0), thekeep=0sunset, and an unparseable version being left inmain.Two of my initial expectations were wrong about the return order — the tests caught that, which is rather the point. 11/11 pass now.
Wired
python3 -m doctest tools/prune.py tools/lines.pyinto thepycompilejob (they had never been run by anything), plus alines.py stable-seriessmoke test so a malformedconfigs/lines.tomlfails in CI rather than mid-build.shellcheck,py_compile,yamllint -d relaxedclean.🤖 Generated with Claude Code
prune.yml was unsafe to run for real. A rotation would have aborted partway through, after deleting from main and before the Fastly purge -- the exact state the code's own comments say the purge exists to prevent. Root cause: SOURCE_COMPONENT was hardcoded to "main" when building the download/delete coordinate, but publishes span TWO components. publish.yml splits the -dbg package into `debug` and everything else into `main`; verified across every manifest in releases/, the split is {'main': 79, 'debug': 12}. So the -dbg file's `main` coordinate 404s, download_file raised SystemExit, and because the rotation was a plain list comprehension that exception killed the whole run -- leaving main's Packages index advertising pool files that no longer exist, and apt 404ing mid-install for users. Fixes: - Resolve each file's component from releases/*/manifest.json's publishes[], which is publish.py's own authoritative record of where it put each file. Fall back to the naming rule mirroring publish.yml (*-dbg_* -> debug) for files published before manifests recorded it, or when a manifest is unreadable -- a malformed manifest must not abort a rotation either. - Contain failures per file. Every step can raise SystemExit; each is now caught, recorded, and the loop continues. Critically: NEVER delete when the archive step failed, so a file is never removed from main without a copy in archive. - Verify the archive copy before deleting. download_file wrote whatever arrived and checked nothing, so a connection dropped at 180 MB of a 227 MB image produced a truncated file that upload_deb would faithfully store (Content-Length comes from os.stat, so the request is self-consistent) -- and then the good copy in main was deleted. Compare the downloaded size against what the registry reports. - ALWAYS purge, over the union of components actually touched plus archive. Previously a hardcoded (main, archive) pair, which left `debug`'s index stale after a -dbg deletion; and the purge was skipped entirely on an abort, which is the worst available outcome. Exit non-zero afterwards with a per-file summary. - Reject --keep < 1. 0 would archive the entire history of every line not in configs/lines.toml, and 7.0 is no longer registered -- so a 0 typed into a dispatch box would sweep eight releases out of main. Per-line keep_in_main = 0 stays the sunset knob: it lives in a committed file that gets read in review, not a workflow input box. - prune.yml built its argv by string concatenation and relied on word-splitting, so keep="3 --owner someoneelse" or distribution="trixie --keep 0" injected flags into a destructive tool. Now validated and passed as an array. The SC2086 suppression that sat there silenced the warning about exactly this, and never did anything anyway: shellcheck does not lint workflow YAML. Tests: select_versions_to_archive had none, despite deciding what gets deleted. Added doctests matching the convention parse_version already used -- newest-N retention, .P-rebuild handling, per-line independence (7.1 churn must not evict 7.0), the keep=0 sunset, and an unparseable version being left in main. Two of my initial expectations were wrong about the return ORDER; the tests caught that, which is the point. Wired `python3 -m doctest tools/prune.py tools/lines.py` into the pycompile job, plus a `lines.py stable-series` smoke test so a malformed configs/lines.toml fails in CI rather than during a build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>