From 6fb063818a820bde73b75aa7e2eca20ebc19ad52 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 24 Aug 2026 12:40:42 -0500 Subject: [PATCH] ci(release): show the error body, retry the POST, and sweep for orphan tags 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 --- .gitea/workflows/release.yml | 106 ++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 17d4292..c07fd79 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -149,6 +149,39 @@ jobs: fi fi + # ── Orphan sweep ──────────────────────────────────────────────── + # + # The check above is VERSION-SCOPED: it only ever asks about the one + # version this run computed. That is enough to recover an orphan on + # the very next run, and useless afterwards — once any releasable + # commit lands, the next run computes a NEW version, never looks at + # the old tag again, and the orphan becomes permanent and silent. + # + # servuo-plugins v0.1.0 is the proof, and the proof is pointed: the + # commit that ADDED the recovery above 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 still orphaned. + # + # So every v* tag is checked, and anything missing a release is + # WARNED about. Deliberately not recovered: 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. + # A human decides whether to recover or drop it. + # + # Never fails the run. A sweep that can break a good release is a + # sweep someone will delete. + ORPHANS="" + for T in $(git tag -l 'v*' --sort=-v:refname); do + T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)" + [ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}" + done + if [ -n "${ORPHANS}" ]; then + echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either." + fi + # Changelog range. A recovery run has nothing after the tag, so # summarize what the tag itself contains rather than emitting an empty # list: the range that produced it, i.e. previous-tag..this-tag. @@ -342,18 +375,75 @@ jobs: # corrupt the Authorization header. CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" - REL_ID="$(curl -sSf -X POST "${API}/releases" \ - -H "Authorization: token ${CI_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ - '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ - | jq -r '.id')" + PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" + + # This POST is the step that orphaned tag v0.1.1 (run 75): it landed one + # second after the tag push and Gitea answered 500, having not finished + # processing the pushed tag. Re-running the workflow published the same + # four assets untouched, so the failure was a race, not a bad request. + # + # Two things went wrong there, and both are fixed here. + # + # 1. `curl -sSf` prints NO response body on an error status, so all the + # log carried was "curl: (22) ... error: 500" and the cause had to be + # inferred from timestamps. Capture the body and print it. + # 2. Nothing retried, so a transient 5xx became a permanent orphan tag. + # The plan step CAN recover one, but only on a run that reaches it -- + # and a later push with no releasable commits stands down before it + # gets there, so in practice the tag sits until a human notices. + # + # 4xx is deliberately NOT retried: a bad token or a malformed body does + # not improve by being sent again, and retrying only turns a clear + # failure into a slow one. + REL_ID="" + for attempt in 1 2 3 4 5; do + HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" || echo 000)" + + if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then + REL_ID="$(jq -r '.id' /tmp/rel.json)" + break + fi + + echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}" + echo "--- response body ---" + cat /tmp/rel.json || true + echo + echo "---------------------" + + case "$HTTP" in + 4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;; + esac + + if [ "$attempt" = 5 ]; then + echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release." + echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it." + exit 1 + fi + sleep $(( attempt * 5 )) + done + + if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then + echo "::error::Release created but no id came back; refusing to upload assets blind." + exit 1 + fi echo "Created release ${TAG} (id=${REL_ID})" for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do - curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + # Same treatment. An upload that fails quietly leaves a release whose + # SHA256SUMS does not cover every binary it advertises, which is worse + # than no release at all -- that file IS the trust anchor. + HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ - -F "attachment=@dist/${f}" >/dev/null + -F "attachment=@dist/${f}" || echo 000)" + if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then + echo "::error::uploading ${f} returned HTTP ${HTTP}" + cat /tmp/asset.json || true + exit 1 + fi echo " uploaded ${f}" done