fix(release): verify before publishing instead of recovering from 409s [release-all] #3

Merged
ryangr0 merged 1 commit from fix/release-verify-before-publish into main 2026-08-04 14:20:49 +00:00
Owner

The bug

Every push to main deleted and re-uploaded all ten package versions. publish_artifact PUT each zip blind and read the resulting 409 as evidence of a "phantom" version, so it DELETEd the live version and re-PUT. They were never phantoms — the published blobs match a local build_dist.py rebuild byte-for-byte. So each run destroyed and recreated ten healthy artifacts (404-ing anyone installing in that window; losing the version outright had a re-PUT failed after its DELETE), then reported green. The repo-link POST was the same shape: the packages are already linked, so it 400'd ten times a run.

The fix — read before writing

  • publish_artifact compares the registry's published sha256 (/files) against a local rebuild. Match → verified, zero writes. The destructive DELETE+PUT now fires only on a real phantom (version with no blob) or genuine drift, warns loudly, and is counted in the summary.
  • No 409 is swallowed. Every write happens only where the state query said it was needed, so a conflict is a concurrent writer and fails the run instead of going green.
  • ensure_release GETs /releases/tags/<tag>; link_package checks repository.full_name first.
  • A quiet run prints one summary line instead of forty lines of churn.

Second bug: stale tags from the history rewrite

last_tag() took the highest-numbered tag regardless of ancestry. Tags left behind by this repo's re-init (domain-language-v1.3.0, skillsmith-v1.1.0, the old repo-wide v1.x) made <tag>..HEAD span unrelated history and return every commit — computing a bump from a version the tree never had, and re-releasing it every run since the stale tag stays the baseline. Only ancestors of HEAD count now. (Those tags are local-only, never pushed, so CI was never affected.)

[release-all]

This PR's title carries the [release-all] token, which patch-releases all ten skills on merge — an estate-wide re-cut on the fixed pipeline. Deliberately not wired into release.yml, where it would re-release everything on every push; the token lives in one commit, so the switch expires the moment that commit is no longer HEAD. Also available as apply --all.

Expected result on merge:

adr-writer            1.0.1 -> 1.0.2      skill-usage            1.0.1 -> 1.0.2
blog-writer           0.1.1 -> 0.1.2      skillsmith             1.0.1 -> 1.0.2
domain-language       1.0.1 -> 1.0.2      vikunja-product-owner  1.2.0 -> 1.2.1
guard-secrets         1.0.1 -> 1.0.2      worktree-herd-dev      1.0.1 -> 1.0.2
harvest-knowledge     1.0.1 -> 1.0.2      bundle                 1.0.0 -> 1.0.1
protocol-fit-research 0.1.0 -> 0.1.1

Each gets a Maintenance release — estate-wide re-cut; no changes to this skill. changelog entry.

Verification

npm run check, npm test, and check_plugin_commits.py origin/main pass. A 9-case harness stubs the registry and asserts exactly which HTTP calls each state produces (healthy → GET only; absent → PUT; phantom/drift → DELETE+PUT; unexpected 409 → non-zero exit; already-linked → no POST; existing release → no POST). apply --all was dry-run end-to-end in a throwaway clone: ten tags, ten changelog entries, bundle bump, marketplace regenerated, and the resulting tree passes lint/sync/manifest checks.

🤖 Generated with Claude Code

## The bug Every push to main **deleted and re-uploaded all ten package versions**. `publish_artifact` PUT each zip blind and read the resulting 409 as evidence of a "phantom" version, so it DELETEd the live version and re-PUT. They were never phantoms — the published blobs match a local `build_dist.py` rebuild byte-for-byte. So each run destroyed and recreated ten healthy artifacts (404-ing anyone installing in that window; losing the version outright had a re-PUT failed after its DELETE), then reported green. The repo-link POST was the same shape: the packages are already linked, so it 400'd ten times a run. ## The fix — read before writing - `publish_artifact` compares the registry's published sha256 (`/files`) against a local rebuild. Match → `verified`, zero writes. The destructive DELETE+PUT now fires **only** on a real phantom (version with no blob) or genuine drift, warns loudly, and is counted in the summary. - **No 409 is swallowed.** Every write happens only where the state query said it was needed, so a conflict is a concurrent writer and fails the run instead of going green. - `ensure_release` GETs `/releases/tags/<tag>`; `link_package` checks `repository.full_name` first. - A quiet run prints one summary line instead of forty lines of churn. ## Second bug: stale tags from the history rewrite `last_tag()` took the highest-numbered tag regardless of ancestry. Tags left behind by this repo's re-init (`domain-language-v1.3.0`, `skillsmith-v1.1.0`, the old repo-wide `v1.x`) made `<tag>..HEAD` span unrelated history and return every commit — computing a bump from a version the tree never had, and re-releasing it every run since the stale tag stays the baseline. Only ancestors of HEAD count now. (Those tags are local-only, never pushed, so CI was never affected.) ## `[release-all]` This PR's title carries the `[release-all]` token, which **patch-releases all ten skills** on merge — an estate-wide re-cut on the fixed pipeline. Deliberately *not* wired into `release.yml`, where it would re-release everything on every push; the token lives in one commit, so the switch expires the moment that commit is no longer HEAD. Also available as `apply --all`. Expected result on merge: ``` adr-writer 1.0.1 -> 1.0.2 skill-usage 1.0.1 -> 1.0.2 blog-writer 0.1.1 -> 0.1.2 skillsmith 1.0.1 -> 1.0.2 domain-language 1.0.1 -> 1.0.2 vikunja-product-owner 1.2.0 -> 1.2.1 guard-secrets 1.0.1 -> 1.0.2 worktree-herd-dev 1.0.1 -> 1.0.2 harvest-knowledge 1.0.1 -> 1.0.2 bundle 1.0.0 -> 1.0.1 protocol-fit-research 0.1.0 -> 0.1.1 ``` Each gets a `Maintenance release — estate-wide re-cut; no changes to this skill.` changelog entry. ## Verification `npm run check`, `npm test`, and `check_plugin_commits.py origin/main` pass. A 9-case harness stubs the registry and asserts exactly which HTTP calls each state produces (healthy → GET only; absent → PUT; phantom/drift → DELETE+PUT; unexpected 409 → non-zero exit; already-linked → no POST; existing release → no POST). `apply --all` was dry-run end-to-end in a throwaway clone: ten tags, ten changelog entries, bundle bump, marketplace regenerated, and the resulting tree passes lint/sync/manifest checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(release): verify before publishing instead of recovering from 409s [release-all]
All checks were successful
CI / Manifests, dist & plugin tests (pull_request) Successful in 12s
fb5fd01628
The publish step PUT each skill's zip blind and read the resulting 409 as
evidence of a phantom version, so every push to main DELETEd all ten live
package versions and re-uploaded them. They were never phantoms: the published
blobs match a local build_dist.py rebuild byte-for-byte. Each run therefore
destroyed and recreated ten healthy artifacts — 404-ing anyone installing in
that window, and losing the version outright had a re-PUT failed after its
DELETE — then reported success. The repo-link POST was the same shape: the
packages are already linked, so it 400'd ten times a run.

Every remote write is now gated on a state query:

- publish_artifact compares the registry's published sha256 (the /files
  endpoint) against a local rebuild. Match -> verified, no write. The
  destructive DELETE+PUT path now fires only on a real phantom (version with no
  blob) or genuine drift, warns loudly, and is counted in the run summary.
- No 409 is swallowed anywhere. A write only happens where the query said it
  was needed, so a conflict means a concurrent writer and fails the run rather
  than going green.
- ensure_release GETs /releases/tags/<tag>; link_package checks
  repository.full_name first.

A quiet run now prints one summary line instead of forty lines of churn.

Also fixes last_tag() picking the highest-numbered tag regardless of ancestry.
Tags left behind by this repo's history rewrite (domain-language-v1.3.0,
skillsmith-v1.1.0, the old repo-wide v1.x) made `<tag>..HEAD` span unrelated
history and yield every commit, computing a bump from a version the tree never
had — and re-releasing forever, since the stale tag stays the baseline. Only
ancestors of HEAD count now.

Adds a one-shot force switch for the estate-wide re-cut this commit triggers:
`apply --all`, or the `[release-all]` token in HEAD's message. Deliberately not
wired into release.yml, where it would re-release everything on every push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
webgrip/ai-skills!3
No description provided.