Release pruning deletes the newest nightly because sort -V doesn't know PEP 440 precedence
Background
The proximate trigger
While deploying the first-ever nightly build (muse-0.2.0.dev1.tar.gz)
during the pivot to the nightly channel (docs/versioning.md),
deploy/publish_muse_release.sh's cleanup step deleted the tarball from
s3://musehub-releases/ seconds after uploading it:
[1/5] Building muse 0.2.0.dev1 ...
[2/5] Uploading to s3://musehub-releases/
[3/5] Pushing to staging ...
[4/5] Cleaning up old releases (keeping 3 newest)
Deleting s3://musehub-releases/muse-0.2.0.dev1.tar.gz ← the file just uploaded
[5/5] Verifying https://staging.musehub.ai/releases/muse-0.2.0.dev1.tar.gz
✅ Live at https://staging.musehub.ai/releases/muse-0.2.0.dev1.tar.gz
The file only "survived" because the verification step reads from the
staging instance's local /data/releases/ copy (populated by the
earlier SSM push step), not from S3. The durable S3 backup copy was
gone — silent data loss that would only surface the next time someone
needed to restore from S3, or the next publish run repeated the same
mistake. Caught and manually re-uploaded from the live URL as an
immediate fix; this issue tracks the actual root cause.
Root cause — verified by direct reproduction, not assumed
Both cleanup blocks (S3, at deploy/publish_muse_release.sh lines
104-113, and the server's /data/releases/, lines 119-127) pipe
muse-*.tar.gz filenames through sort -V (GNU "version sort") before
keeping only the newest KEEP_RELEASES (currently 3). Reproduced
exactly with the four files that existed on staging at deploy time:
$ printf "muse-0.2.0rc14.tar.gz\nmuse-0.2.0rc15.tar.gz\nmuse-0.2.0rc16.tar.gz\nmuse-0.2.0.dev1.tar.gz\n" | sort -V
muse-0.2.0.dev1.tar.gz ← sorted FIRST (== "oldest" to the keep-N logic)
muse-0.2.0rc14.tar.gz
muse-0.2.0rc15.tar.gz
muse-0.2.0rc16.tar.gz
sort -V is a generic natural/version sort (the same tool that
correctly orders file1, file2, file10) — it has zero knowledge of PEP
440's reserved-segment precedence (dev < a < b < rc < final,
documented in docs/versioning.md's "A real gotcha" section). It
compares "dev1" against "rc14" as plain text, and 'd' sorts before
'r' — placing the newest channel (nightly) before three older
release candidates. The keep-newest-3 awk logic then deletes line 1,
i.e. exactly the file that should have been kept.
Verified the correct order via the tool that actually understands PEP 440 precedence:
$ python3 -c "
from packaging.version import Version
names = ['0.2.0.dev1', '0.2.0a1', '0.2.0b1', '0.2.0rc14', '0.2.0rc15', '0.2.0rc16']
for n in sorted(names, key=Version): print(n)
"
0.2.0.dev1
0.2.0a1
0.2.0b1
0.2.0rc14
0.2.0rc15
0.2.0rc16
packaging.version.Version (already a transitive dependency of
build/twine, both already required by this exact script) sorts all
six real channel examples in the correct maturity order documented in
docs/versioning.md. This is not a hypothetical edge case — it is the
exact tool needed, already present in the toolchain.
Why this isn't a one-line fix
sort -V is invoked in two places with slightly different
execution contexts:
- S3 cleanup — runs locally, in the deploy script's own shell,
where Python +
packagingare guaranteed available (the script already builds an sdist viapython3 -m build). - Server cleanup — runs remotely via SSM
(
AWS-RunShellScripton the staging EC2 instance's host shell, not inside themusehubDocker container) — Python/packagingavailability on the bare host is not guaranteed the same way.
The correct fix is to compute the stale-file list once, locally
(where Python is guaranteed present) and pass that exact, precomputed
list to both the S3 delete calls and the remote SSM rm command —
eliminating the need for the remote host to re-implement version
sorting at all, and eliminating the current duplication of the same
buggy sort logic in two places.
Design — the shape to build toward
A small, dependency-free-of-bash-quirks Python helper,
deploy/prune_releases.py:
def stale_releases(filenames: list[str], keep: int) -> list[str]:
"""Return filenames to delete — all but the *keep* highest-precedence
releases, ordered oldest-first. Never guesses on an unparseable name:
raises rather than silently mis-sorting."""
- Parses
muse-{version}.tar.gz→packaging.version.Versionfor the sort key — the same canonical PEP 440 precedencedocs/versioning.mddocuments. - An unparseable filename (doesn't match the pattern, or the version segment isn't valid PEP 440) is a hard error, not a silent skip or a fallback to lexical sort — a malformed release name is exactly the kind of thing that must stop the pipeline, not quietly mis-order it.
- CLI entry point (
--keep N <filenames...>, prints stale names one per line) so both the local S3 step and the remote SSM step can consume it identically viaxargs/while read.
publish_muse_release.sh changes:
- S3 cleanup: pipe the
aws s3 lsfilename list throughprune_releases.py --keep "$KEEP_RELEASES"instead ofsort -V | awk .... - Server cleanup: list remote files via one SSM command
(
ls /data/releases/muse-*.tar.gz), compute the stale set locally with the sameprune_releases.pycall, then send a second SSM command with an explicitrmof the exact stale filenames — the remote shell never sorts anything itself.
Goal — definition of done
- Deploying a nightly build never deletes it (or any other currently-newest release) from S3 or the server.
- Pruning correctly follows PEP 440 channel precedence
(
dev < a < b < rc < final) regardless of which channels are mixed in the release history at prune time. - An unparseable/malformed release filename halts pruning with a clear error rather than silently mis-ordering everything else.
sort -V's buggy precedence logic exists in exactly zero places in this script after the fix (both cleanup blocks use the same, correct, single source of truth).- Every deliverable is TDD'd: red test first, then green.
Phases
Phase 1 — prune_releases.py, in isolation
- [ ]
PRUNE_01—stale_releases(["muse-0.2.0rc14.tar.gz", "muse-0.2.0rc15.tar.gz", "muse-0.2.0rc16.tar.gz", "muse-0.2.0.dev1.tar.gz"], keep=3)returns["muse-0.2.0rc14.tar.gz"]— the actual reproduction case from this incident, pinned as a regression test. - [ ]
PRUNE_02— Mixed channels (dev1,a1,b1,rc1,rc2) withkeep=2returns everything except the two highest-precedence (rc1,rc2) — full channel-order coverage, not just the dev-vs-rc pair. - [ ]
PRUNE_03—keep >= len(filenames)returns[]— never deletes when there's nothing to prune. - [ ]
PRUNE_04— An unparseable filename (e.g.muse-not-a-version.tar.gz) raises rather than silently sorting it somewhere arbitrary. - [ ]
PRUNE_05— Stable releases (0.2.0, no pre-release suffix) sort after all pre-release channels, per PEP 440 — a mix ofdev1/rc1/stable keeps the stable one.
Exit gate: stale_releases is correct in isolation for every channel
combination in docs/versioning.md's Build Channels table, with the
exact incident reproduction as a named regression test.
Phase 2 — Wire into publish_muse_release.sh
- [ ]
PRUNE_06— S3 cleanup callsprune_releases.pyinstead ofsort -V | awk. Test (shell-level, via a fixture directory of fake tarball names and a mockedaws s3 ls/aws s3 rm, or a focused manual dry-run against a throwaway S3 prefix): the exactdev1-survives-rc14-deleted case no longer reproduces. - [ ]
PRUNE_07— Server cleanup computes the stale set locally and sends the remotermwith explicit filenames — no sort logic executes on the remote host at all. Verified by reading the exact SSM command string sent (nosortinvocation present).
Exit gate: re-running the exact reproduction command from the
Background section against the real script (dry-run or a throwaway
bucket) confirms muse-0.2.0.dev1.tar.gz is correctly identified as the
one to keep, and the three rc1x files as the ones to prune.
Phase 3 — Live re-verification
- [ ]
PRUNE_08— Run the realpublish_muse_release.shfor the next scheduled nightly/release bump, confirm viaaws s3 ls s3://musehub-releases/that the newest build survives pruning and the correct oldest ones are removed.
Exit gate: a real deploy cycle completes with correct pruning, observed live, not just unit-tested.
Acceptance criteria (whole-issue gate)
sort -Vno longer appears anywhere inpublish_muse_release.sh.- Pruning is correct for every channel combination in
docs/versioning.md's Build Channels table. - The exact incident (nightly deleted from S3 immediately after publish) cannot recur — pinned by a named regression test.
- Full TDD coverage: every phase's deliverables have a red-then-green test.
- Verified live on a real deploy cycle, not just unit-tested.
Out of scope (explicit, for future issues)
- Any change to the server-side
/data/releases/retention policy itself (e.g. keeping more than 3, or keeping one-per-channel) — this issue is about correctness of the existing keep-N-newest policy, not changing the policy. muse/core/semver.pyimplementing precedence comparison — the versioning doc already notes this isn't currently implemented and isn't a live bug there; this issue's fix lives entirely in the deploy script, usingpackaging.version.Version(already available), not in muse's own SemVer module.