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

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

This file is the ancestor of installer's release.yml, as its own header says — and installer#22's release run found two gaps in it the hard way. link has not hit them, but it has both verbatim.

Companion PRs: installer#23 and servuo-plugins#15.

What happened over in installer

Run 75 built every artifact, pushed tag v0.1.1, then took a 500 from POST /releases one second later and exited 22. No release, no binaries, and the fix in #22 reached no operator until someone re-ran the workflow by hand. Re-running published the same four assets untouched, so it was a race with the tag push, not a bad request.

1. The failure is unreadable. curl -sSf prints no response body on an error status, so the entire log is:

curl: (22) The requested URL returned error: 500
##[error]Process completed with exit code 22.

Every call now captures the body and prints it on failure.

2. Nothing retries. The POST now retries 5 times with a 5/10/15/20s backoff. 4xx is deliberately not retried — a bad token or malformed body does not improve by being sent again, and retrying only turns a clear failure into a slow one.

Asset uploads get the same treatment: a release whose SHA256SUMS does not cover every binary it advertises is worse than no release, because that file is the trust anchor for an unsigned download.

The third gap — which I got wrong the first time

In installer#23 I wrote that the plan step "can recover an orphan, but only on a run that reaches it". That understates it.

The recovery is version-scoped. It computes VERSION from the newest tag plus the bump, then only ever checks refs/tags/v${VERSION}. That recovers an orphan on the very next run and is useless afterwards — once any releasable commit lands, the next run computes a new version and never looks at the old tag again.

servuo-plugins v0.1.0 proved it, 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

Typed fix(...), so it bumped to v0.1.1 — and the run that introduced the recovery stepped straight past the tag it was written to rescue. That 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 also never fails the run — a sweep that can break a good release is a sweep someone will delete.

Verification

No Rust changed. Both steps were extracted from the YAML and exercised:

PASS  first try succeeds                     rc=0, 0 retried attempt(s)
PASS  500 then success (the v0.1.1 race)     rc=0, 1 retried attempt(s)
PASS  two 500s then success                  rc=0, 2 retried attempt(s)
PASS  500 five times gives up                rc=1, 5 retried attempt(s)
PASS  403 aborts immediately                 rc=1, 1 retried attempt(s)
PASS  404 aborts immediately                 rc=1, 1 retried attempt(s)
PASS  000 (network) is retried               rc=0, 1 retried attempt(s)

And the sweep run against the real repositories, which is the test that matters since it had to tell a clean repo from a dirty one:

link (9 tags): clean
servuo-plugins (4 tags): ::warning::Tags with no release: v0.1.0
installer (2 tags): clean

bash -n clean, YAML parses, and no empty ${{ }} token — the trap that silently skipped three workflows for months.

Note on the commit type

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


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

🤖 Generated with Claude Code

This file is the **ancestor** of `installer`'s `release.yml`, as its own header says — and installer#22's release run found two gaps in it the hard way. `link` has not hit them, but it has both verbatim. Companion PRs: **installer#23** and **servuo-plugins#15**. ## What happened over in installer Run 75 built every artifact, pushed tag `v0.1.1`, then took a `500` from `POST /releases` **one second later** and exited 22. No release, no binaries, and the fix in #22 reached no operator until someone re-ran the workflow by hand. Re-running published the same four assets untouched, so it was a **race with the tag push**, not a bad request. ## The two gaps `link` shares **1. The failure is unreadable.** `curl -sSf` prints no response body on an error status, so the entire log is: ``` curl: (22) The requested URL returned error: 500 ##[error]Process completed with exit code 22. ``` Every call now captures the body and prints it on failure. **2. Nothing retries.** The POST now retries **5 times with a 5/10/15/20s backoff**. `4xx` is deliberately **not** retried — a bad token or malformed body does not improve by being sent again, and retrying only turns a clear failure into a slow one. Asset uploads get the same treatment: a release whose `SHA256SUMS` does not cover every binary it advertises is worse than no release, because that file is the trust anchor for an unsigned download. ## The third gap — which I got wrong the first time In installer#23 I wrote that the plan step "can recover an orphan, but only on a run that reaches it". That understates it. **The recovery is version-scoped.** It computes `VERSION` from the newest tag plus the bump, then only ever checks `refs/tags/v${VERSION}`. That recovers an orphan on the *very next* run and is useless afterwards — once any releasable commit lands, the next run computes a **new** version and never looks at the old tag again. `servuo-plugins` v0.1.0 proved it, 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` Typed `fix(...)`, so it bumped to v0.1.1 — and the run that introduced the recovery **stepped straight past the tag it was written to rescue**. That 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 also **never fails the run** — a sweep that can break a good release is a sweep someone will delete. ## Verification No Rust changed. Both steps were extracted from the YAML and exercised: ``` PASS first try succeeds rc=0, 0 retried attempt(s) PASS 500 then success (the v0.1.1 race) rc=0, 1 retried attempt(s) PASS two 500s then success rc=0, 2 retried attempt(s) PASS 500 five times gives up rc=1, 5 retried attempt(s) PASS 403 aborts immediately rc=1, 1 retried attempt(s) PASS 404 aborts immediately rc=1, 1 retried attempt(s) PASS 000 (network) is retried rc=0, 1 retried attempt(s) ``` And the sweep run against the **real repositories**, which is the test that matters since it had to tell a clean repo from a dirty one: ``` link (9 tags): clean servuo-plugins (4 tags): ::warning::Tags with no release: v0.1.0 installer (2 tags): clean ``` `bash -n` clean, YAML parses, and no empty `${{ }}` token — the trap that silently skipped three workflows for months. ## Note on the commit type Typed **`ci(...)` rather than `fix(...)` on purpose.** The plan step bumps on `feat`/`fix` subjects; this changes no binary, so a `fix(...)` here would cut an empty release. That is the same rule the `fix(release):` commit above tripped over. --- 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:00 +00:00
ci(release): show the error body, retry the POST, and sweep for orphan tags
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m42s
6fb063818a
This file is the ancestor of installer's release.yml, and installer#22's
release run found two gaps in it the hard way: the 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 published.

link has not hit that, but it has the same two gaps verbatim.

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

And nothing retried, so a transient 5xx becomes 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,
and retrying would turn a clear failure into a slow one.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

The third gap is the one worth reading. 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.

servuo-plugins v0.1.0 proves it, and the proof is pointed: the commit that
ADDED that recovery was itself typed "fix(release): ... recover the orphaned
v0.1.0 tag", so it bumped to v0.1.1 and the run that introduced the recovery
stepped straight past the tag it was written to rescue. That tag is orphaned to
this day.

So the plan step now sweeps every v* tag and warns about any without a release.
Deliberately warns rather than recovers: 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 also never fails the run -- a
sweep that can break a good release is a sweep someone will delete.

Verified by extracting both steps from the YAML and running them: bash -n
clean, the YAML parses, no empty template token in either step, the retry loop
exercised against a stubbed curl across seven cases (first-try success,
500-then-success, two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure retried), and the sweep
run against the real repositories -- link clean, servuo-plugins reporting
v0.1.0, installer clean.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, 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:15 +00:00
whitlocktech merged commit 4b8ea768b6 into main 2026-08-24 19:42:22 +00:00
whitlocktech deleted branch ci/release-post-retry-and-error-body 2026-08-24 19:42:22 +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/link#33
No description provided.