ci(release): show the error body, retry the POST, and sweep for orphan tags #15

Merged
whitlocktech merged 1 commits from ci/release-post-retry-and-error-body into main 2026-08-24 19:42:43 +00:00
Member

Same hardening as installer#23 and link#33 — but this repo is where the whole problem was first seen, and it turned out to still be carrying the scar.

The two gaps, shared verbatim

installer#22's release run built every artifact, pushed its tag, then took a 500 from POST /releases one second later and exited 22. Re-running published it unchanged, so it was a race with the tag push, not a bad request.

1. curl -sSf prints no response body on an error status, so the cause had to be inferred from timestamps. Every call now captures the body and prints it.

2. Nothing retried, so a transient 5xx became a permanent orphan. The POST now retries 5 times with a 5/10/15/20s backoff; 4xx is deliberately not retried.

The asset upload gets the same treatment, and it matters more here than anywhere else: this release ships an overlay tarball plus SHA256SUMS, the installer verifies against that file, and a release whose checksums do not cover the tarball they advertise is worse than no release at all.

The third gap — and this repo is the proof

The orphan-tag recovery in the plan step is version-scoped: it computes VERSION from the newest tag plus the bump, then only ever checks refs/tags/v${VERSION}. It recovers an orphan on the very next run and is useless afterwards, because once any releasable commit lands the next run computes a new version and never looks back.

v0.1.0 in this repository had been orphaned since 2026-08-04 — tag present, no release, no assets — while v0.1.1, v0.2.0 and v1.0.0 all published normally.

The reason is worth reading twice. The commit that added the recovery was itself:

fix(release): preflight credentials and recover the orphaned v0.1.0 tag

Typed fix(...), so it bumped the version to v0.1.1 — and the run that introduced the recovery stepped straight past the tag it was written to rescue. It never looked back, and could not have.

So the plan step now sweeps every v* tag and warns about any without a release. It warns rather than recovers, deliberately: publishing an old version would mean building today's tree and shipping it under a tag whose tree it is not. And it never fails the run — a sweep that can break a good release is a sweep someone will delete.

v0.1.0 is deleted

On the org lead's decision. Checked first: it is three releases behind, and no published bundle names it — not even bundle-2026.08.04.json, because the tag never had a release for a bundle to point at.

It was 724262548b8193ff7d31ed92136ad449464d66e6, the merge of #7, recorded in the commit message so it can be recreated if that turns out to be wrong.

Verification

Every run block extracted from the YAML and bash -n checked, YAML parses, no empty ${{ }} token.

The asset loop was preserved rather than normalised — this repo ships for f in "${TARBALL}" SHA256SUMS, not link's three binaries, and a port that flattened that would ship the wrong artifacts.

Retry loop against a stubbed curl, seven cases, all passing (first-try success, the 500-then-success race, two 500s, five 500s giving up, 403 and 404 aborting without retry, and a 000 network failure retried).

Sweep against the real repositories, before and after the deletion:

before:  servuo-plugins (4 tags): ::warning::Tags with no release: v0.1.0
after:   servuo-plugins (3 tags): clean

Note on the commit type

Typed ci(...) rather than fix(...) on purpose — the plan step bumps on feat/fix, and this changes no artifact, so a fix(...) here would cut an empty release. Which is precisely the rule that stranded v0.1.0 in the first place.


AI-assisted: written with Claude Code (Opus 5).

🤖 Generated with Claude Code

Same hardening as **installer#23** and **link#33** — but this repo is where the whole problem was first seen, and it turned out to still be carrying the scar. ## The two gaps, shared verbatim installer#22's release run built every artifact, pushed its tag, then took a `500` from `POST /releases` **one second later** and exited 22. Re-running published it unchanged, so it was a **race with the tag push**, not a bad request. **1. `curl -sSf` prints no response body on an error status**, so the cause had to be inferred from timestamps. Every call now captures the body and prints it. **2. Nothing retried**, so a transient 5xx became a permanent orphan. The POST now retries **5 times with a 5/10/15/20s backoff**; `4xx` is deliberately not retried. The **asset upload** gets the same treatment, and it matters more here than anywhere else: this release ships an overlay tarball plus `SHA256SUMS`, **the installer verifies against that file**, and a release whose checksums do not cover the tarball they advertise is worse than no release at all. ## The third gap — and this repo is the proof The orphan-tag recovery in the plan step is **version-scoped**: it computes `VERSION` from the newest tag plus the bump, then only ever checks `refs/tags/v${VERSION}`. It recovers an orphan on the *very next* run and is useless afterwards, because once any releasable commit lands the next run computes a **new** version and never looks back. **`v0.1.0` in this repository had been orphaned since 2026-08-04** — tag present, no release, no assets — while v0.1.1, v0.2.0 and v1.0.0 all published normally. The reason is worth reading twice. The commit that **added** the recovery was itself: ``` fix(release): preflight credentials and recover the orphaned v0.1.0 tag ``` Typed `fix(...)`, so it bumped the version to v0.1.1 — and the run that introduced the recovery **stepped straight past the tag it was written to rescue**. It never looked back, and could not have. So the plan step now **sweeps every `v*` tag** and warns about any without a release. It **warns rather than recovers**, deliberately: publishing an old version would mean building today's tree and shipping it under a tag whose tree it is not. And it **never fails the run** — a sweep that can break a good release is a sweep someone will delete. ## v0.1.0 is deleted On the org lead's decision. Checked first: it is three releases behind, and **no published bundle names it** — not even `bundle-2026.08.04.json`, because the tag never had a release for a bundle to point at. It was `724262548b8193ff7d31ed92136ad449464d66e6`, the merge of #7, recorded in the commit message so it can be recreated if that turns out to be wrong. ## Verification Every run block extracted from the YAML and `bash -n` checked, YAML parses, no empty `${{ }}` token. The asset loop was **preserved rather than normalised** — this repo ships `for f in "${TARBALL}" SHA256SUMS`, not link's three binaries, and a port that flattened that would ship the wrong artifacts. Retry loop against a stubbed `curl`, seven cases, all passing (first-try success, the 500-then-success race, two 500s, five 500s giving up, 403 and 404 aborting without retry, and a 000 network failure retried). Sweep against the **real repositories**, before and after the deletion: ``` before: servuo-plugins (4 tags): ::warning::Tags with no release: v0.1.0 after: servuo-plugins (3 tags): clean ``` ## Note on the commit type Typed **`ci(...)` rather than `fix(...)` on purpose** — the plan step bumps on `feat`/`fix`, and this changes no artifact, so a `fix(...)` here would cut an empty release. Which is precisely the rule that stranded v0.1.0 in the first place. --- AI-assisted: written with Claude Code (Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-08-24 18:56:27 +00:00
installer#22's release run built every artifact, pushed its tag, then took a 500
from POST /releases one second later and exited 22 -- leaving the tag orphaned
with no binaries. Re-running published it unchanged, so the 500 was a race with
the tag push rather than a bad request.

This repo's release step has the same two gaps verbatim, and it is where the
whole problem was first seen.

`curl -sSf` prints no response body on an error status, so such a failure leaves
only "curl: (22) ... error: 500" in the log and the cause has to be inferred
from timestamps. Every call now captures the body and prints it on failure.

Nothing retried, so a transient 5xx became a permanent orphan. The POST now
retries five times with a 5/10/15/20s backoff. 4xx is deliberately not retried:
a bad token or a malformed body will not improve by being sent again.

The asset upload gets the same treatment. That matters more here than anywhere
else: this release ships an overlay tarball and a SHA256SUMS, and a release
whose checksums do not cover the tarball they advertise is worse than no
release, because that file is the trust anchor and the installer verifies
against it.

The third gap is the one this repo proves. The orphan-tag recovery in the plan
step is VERSION-SCOPED -- it computes VERSION from the newest tag plus the bump,
then only checks refs/tags/v${VERSION}. That recovers an orphan on the very next
run and is useless afterwards, because once any releasable commit lands the next
run computes a NEW version and never looks at the old tag again.

v0.1.0 was the proof, and the proof is pointed: the commit that ADDED that
recovery was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped the version to v0.1.1 -- and the run that
introduced the recovery stepped straight past the tag it was written to rescue.
The tag stayed orphaned from 2026-08-04 until today.

So the plan step now sweeps every v* tag and warns about any without a release.
It warns rather than recovers, deliberately: publishing an old version would
mean building today's tree and shipping it under a tag whose tree it is not,
which is worse than the inconsistency it fixes. It never fails the run either --
a sweep that can break a good release is a sweep someone will delete.

v0.1.0 itself is deleted, on the org lead's decision. Nothing referenced it: it
is three releases behind, and no published bundle names it -- not even
bundle-2026.08.04, because the tag never had a release for a bundle to point at.
It was 724262548b, the merge of #7, recorded here
so the tag can be recreated if that turns out to be wrong.

Verified by extracting every run block from the YAML: bash -n clean across all
of them, the YAML parses, no empty template token, the asset loop still the
tarball-and-checksums pair rather than link's three binaries, the retry loop
exercised against a stubbed curl across seven cases, and the sweep run against
the real repositories -- reporting v0.1.0 before the deletion and clean after.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no artifact, so a release here would be an empty one. That is
the same rule the fix commit above tripped over.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-24 19:42:35 +00:00
whitlocktech merged commit 0d9ac7fda8 into main 2026-08-24 19:42:43 +00:00
whitlocktech deleted branch ci/release-post-retry-and-error-body 2026-08-24 19:42:45 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#15
No description provided.