fix: publish Forgejo releases atomically
All checks were successful
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Successful in 3m58s
All checks were successful
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Successful in 3m58s
This commit is contained in:
parent
2ab9902c38
commit
c4315c2cfa
1 changed files with 90 additions and 19 deletions
|
|
@ -25,12 +25,12 @@ forgejo_api_base() {
|
||||||
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
||||||
# @kimi on !193 before it shipped):
|
# @kimi on !193 before it shipped):
|
||||||
#
|
#
|
||||||
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
||||||
# 404s; a repo-less path read
|
# 404s; a repo-less path read
|
||||||
# as "the release does not
|
# as "the published release
|
||||||
# exist" — and the
|
# does not exist" — and the
|
||||||
# nothing-exists assert would
|
# nothing-exists assert would
|
||||||
# then proceed to CREATE)
|
# then proceed to CREATE)
|
||||||
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
||||||
# PR behind it, read as none)
|
# PR behind it, read as none)
|
||||||
#
|
#
|
||||||
|
|
@ -672,22 +672,31 @@ forgejo_read_code() {
|
||||||
rm -f "$hdr"
|
rm -f "$hdr"
|
||||||
}
|
}
|
||||||
|
|
||||||
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
# forge_release_exists <tag> — prints `yes` for a published release and `no`
|
||||||
# the read did not complete and the answer is UNKNOWN; the caller must not
|
# for a draft or a 404. A non-zero exit means the read did not complete and
|
||||||
# treat that as `no` (#191).
|
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
|
||||||
forge_release_exists() {
|
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)"
|
body="$(mktemp)"
|
||||||
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
||||||
rm -f "$body"
|
|
||||||
case "$code" in
|
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 ;;
|
404) echo no ;;
|
||||||
*)
|
*)
|
||||||
|
rm -f "$body"
|
||||||
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
||||||
return 1
|
return 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
rm -f "$body"
|
||||||
}
|
}
|
||||||
|
|
||||||
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||||
|
|
@ -765,20 +774,74 @@ forgejo_urlencode() {
|
||||||
jq -rn --arg s "${1-}" '$s|@uri'
|
jq -rn --arg s "${1-}" '$s|@uri'
|
||||||
}
|
}
|
||||||
|
|
||||||
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
|
||||||
# uploads each asset to the created release. The release id comes back from
|
# post-create failure. The caller has already printed the original failure;
|
||||||
# the create, so no second lookup is needed.
|
# 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() {
|
forge_release_create() {
|
||||||
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
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 notes="${3:?forge_release_create: notes file required}" out id base token
|
||||||
|
local existing code draft existing_id
|
||||||
shift 3
|
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" \
|
out="$(forgejo_write POST "repos/$REPO/releases" \
|
||||||
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
"$(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')"
|
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
||||||
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
||||||
[ "$#" -gt 0 ] || return 0
|
if ! base="$(forgejo_api_base)"; then
|
||||||
base="$(forgejo_api_base)" || return 1
|
forgejo_release_cleanup_draft "$id" "$tag"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
local f name
|
local f name
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
|
|
@ -792,8 +855,16 @@ forge_release_create() {
|
||||||
curl -sS -f -X POST -H "Authorization: token $token" \
|
curl -sS -f -X POST -H "Authorization: token $token" \
|
||||||
-F "attachment=@$f" \
|
-F "attachment=@$f" \
|
||||||
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
"$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
|
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
|
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue