fix(prune): resolve each file's real component; never abort mid-rotation #37

Merged
zach merged 1 commit from fix/prune-component-resolution into fix/workflow-injection-and-http-status 2026-07-25 21:46:45 +00:00
Owner

Stacked on #29 (fix/workflow-injection-and-http-status), which added the no-redirect openers this builds on.

⚠️ prune.yml is unsafe to run for real until this lands.

A real rotation would have aborted partway through

Hardcoded SOURCE_COMPONENT = "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/: {'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 — after deleting from main and before the Fastly purge. That leaves main's Packages index 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

  • Resolve each file's component from releases/*/manifest.json's publishes[]publish.py's own authoritative record of where it put each file. Falls 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 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 — 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. Now compares the downloaded size against what the registry reports.
  • Always purge, over the union of components actually touched ∪ archive. Previously a hardcoded (main, archive) pair, leaving debug's index stale after a -dbg deletion — and the purge was skipped entirely on an abort, which is the worst available outcome. Exits 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 # shellcheck disable=SC2086 that 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 -dbg file resolves to debug, an image file to main; the fallback rule handles unknown files in both directions.

Safety properties, with the HTTP layer stubbed:

case result
one file's download fails 3 attempted, 2 succeeded, 1 recorded — the failing file was not deleted
archive upload fails for all 0 deletes attempted; every action records an error
truncated download detected on all 3 files, 0 deletes attempted
components used in one rotation ['debug', 'main'] — both, correctly

--keep floor: 3 and 1 accepted; 0, -1, abc all SystemExit(2) with the message pointing at keep_in_main for a real sunset.

Tests

select_versions_to_archive had none, despite being the function that decides 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 rather the point. 11/11 pass now.

Wired python3 -m doctest tools/prune.py tools/lines.py into the pycompile job (they had never been run by anything), plus a lines.py stable-series smoke test so a malformed configs/lines.toml fails in CI rather than mid-build.

shellcheck, py_compile, yamllint -d relaxed clean.

🤖 Generated with Claude Code

> **Stacked on #29** (`fix/workflow-injection-and-http-status`), which added the no-redirect openers this builds on. > > ⚠️ **`prune.yml` is unsafe to run for real until this lands.** ## A real rotation would have aborted partway through Hardcoded `SOURCE_COMPONENT = "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/`: **`{'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 — **after** deleting from `main` and **before** the Fastly purge. That leaves `main`'s `Packages` index 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 - **Resolve each file's component** from `releases/*/manifest.json`'s `publishes[]` — `publish.py`'s own authoritative record of where it put each file. Falls 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 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 — 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. Now compares the downloaded size against what the registry reports. - **Always purge**, over the union of components actually touched ∪ `archive`. Previously a hardcoded `(main, archive)` pair, leaving `debug`'s index stale after a `-dbg` deletion — and the purge was skipped entirely on an abort, which is the worst available outcome. Exits 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 `# shellcheck disable=SC2086` that 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 `-dbg` file resolves to `debug`, an image file to `main`; the fallback rule handles unknown files in both directions. Safety properties, with the HTTP layer stubbed: | case | result | |---|---| | one file's download fails | 3 attempted, 2 succeeded, 1 recorded — **the failing file was not deleted** | | archive upload fails for all | **0 deletes attempted**; every action records an error | | truncated download | detected on all 3 files, **0 deletes attempted** | | components used in one rotation | `['debug', 'main']` — both, correctly | `--keep` floor: `3` and `1` accepted; `0`, `-1`, `abc` all `SystemExit(2)` with the message pointing at `keep_in_main` for a real sunset. ## Tests `select_versions_to_archive` had **none**, despite being the function that decides 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 rather the point. 11/11 pass now. Wired `python3 -m doctest tools/prune.py tools/lines.py` into the `pycompile` job (they had never been run by anything), plus a `lines.py stable-series` smoke test so a malformed `configs/lines.toml` fails in CI rather than mid-build. `shellcheck`, `py_compile`, `yamllint -d relaxed` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(prune): resolve each file's real component; never abort mid-rotation
All checks were successful
validate / pycompile (pull_request) Successful in 5s
validate / shellcheck (pull_request) Successful in 13s
validate / yamllint (pull_request) Successful in 12s
validate / workflow-expressions (pull_request) Successful in 4s
validate / intent-matches-policy (pull_request) Successful in 5s
validate / dep-list-parity (pull_request) Successful in 4s
validate / no-placeholder-digests (pull_request) Has been skipped
0f22ab5733
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>
zach merged commit a1d6a2d873 into fix/workflow-injection-and-http-status 2026-07-25 21:46:45 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
unredacted/linux-hardened-unredacted!37
No description provided.