From c4315c2cfab410413364465831e37bcfb2ddc8c8 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Sun, 30 Aug 2026 09:25:43 +0000 Subject: [PATCH] fix: publish Forgejo releases atomically --- lib/forge-forgejo.sh | 109 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 19 deletions(-) diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 415f0d6..1f228a1 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -25,12 +25,12 @@ forgejo_api_base() { # THE TRAP, measured on this instance with REPO unset (#191, caught by # @kimi on !193 before it shipped): # - # forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1 - # 404s; a repo-less path read - # as "the release does not - # exist" — and the - # nothing-exists assert would - # then proceed to CREATE) +# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1 +# 404s; a repo-less path read +# as "the published release +# does not exist" — and the +# nothing-exists assert would +# then proceed to CREATE) # forge_commit_pulls -> "[]", rc 0 (a commit that HAS a merged # PR behind it, read as none) # @@ -672,22 +672,31 @@ forgejo_read_code() { rm -f "$hdr" } -# forge_release_exists — prints `yes` or `no`. A non-zero exit means -# the read did not complete and the answer is UNKNOWN; the caller must not -# treat that as `no` (#191). +# forge_release_exists — prints `yes` for a published release and `no` +# for a draft or a 404. A non-zero exit means the read did not complete and +# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271). forge_release_exists() { - local tag="${1:?forge_release_exists: tag required}" body code + local tag="${1:?forge_release_exists: tag required}" body code draft body="$(mktemp)" code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; } - rm -f "$body" case "$code" in - 2*) echo yes ;; + 2*) + if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \ + || [[ "$draft" != true && "$draft" != false ]]; then + rm -f "$body" + echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2 + return 1 + fi + [ "$draft" = true ] && echo no || echo yes + ;; 404) echo no ;; *) + rm -f "$body" echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2 return 1 ;; esac + rm -f "$body" } # forge_commit_pulls — the pull requests whose merge produced , as @@ -765,20 +774,74 @@ forgejo_urlencode() { jq -rn --arg s "${1-}" '$s|@uri' } -# forge_release_create <notes-file> [asset…] — publishes, then -# uploads each asset to the created release. The release id comes back from -# the create, so no second lookup is needed. +# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a +# post-create failure. The caller has already printed the original failure; +# cleanup can add evidence but must never replace that diagnosis (#271). +forgejo_release_cleanup_draft() { + local id="${1:?forgejo_release_cleanup_draft: id required}" + local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup + if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then + [ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2 + echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2 + fi + return 0 +} + +# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft, +# uploads every asset, then publishes it. Any failure after create removes the +# draft, so the function leaves either a complete published release or nothing. forge_release_create() { local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}" local notes="${3:?forge_release_create: notes file required}" out id base token + local existing code draft existing_id shift 3 + + # A previous rollback whose DELETE failed must not wedge the next attempt. + # Remove only a draft for this exact tag; a published release is never + # touched and its create will retain Forgejo's ordinary conflict refusal. + existing="$(mktemp)" + code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; } + case "$code" in + 2*) + if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \ + || [[ "$draft" != true && "$draft" != false ]]; then + rm -f "$existing" + echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2 + return 1 + fi + if [ "$draft" = true ]; then + existing_id="$(jq -r '.id // empty' "$existing")" + if [ -z "$existing_id" ]; then + rm -f "$existing" + echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2 + return 1 + fi + echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2 + if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then + rm -f "$existing" + echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2 + return 1 + fi + fi + ;; + 404) ;; + *) + rm -f "$existing" + echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2 + return 1 + ;; + esac + rm -f "$existing" + out="$(forgejo_write POST "repos/$REPO/releases" \ "$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \ - '{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1 + '{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1 id="$(printf '%s' "$out" | jq -r '.id // empty')" [ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; } - [ "$#" -gt 0 ] || return 0 - base="$(forgejo_api_base)" || return 1 + if ! base="$(forgejo_api_base)"; then + forgejo_release_cleanup_draft "$id" "$tag" + return 1 + fi token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" local f name for f in "$@"; do @@ -792,8 +855,16 @@ forge_release_create() { curl -sS -f -X POST -H "Authorization: token $token" \ -F "attachment=@$f" \ "$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \ - || { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; } + || { + echo "forge_release_create: asset upload failed for '$f'" >&2 + forgejo_release_cleanup_draft "$id" "$tag" + return 1 + } done + if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then + forgejo_release_cleanup_draft "$id" "$tag" + return 1 + fi } # forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes