fix: publish Forgejo releases atomically #272

Merged
andres merged 6 commits from build/271-atomic-forgejo-release into main 2026-08-30 10:47:56 +00:00
5 changed files with 285 additions and 46 deletions

View file

@ -363,6 +363,22 @@ jobs:
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step.

View file

@ -109,7 +109,7 @@ consumer's release, when it is stale.)
**The merge is the ship decision; the tag is transcription.** After the
merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its
way to certainty, tags the merge commit, publishes the GitHub release with
way to certainty, tags the merge commit, publishes the forge release with
the version's own changelog section as the body — the curated prose, never
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
@ -127,7 +127,7 @@ steps run past the tag, and what a failure at each leaves behind is what
sorts them. Two fail before the release exists: the consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
the publish, so its non-zero exit aborts, and the publish itself
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258))
([`forge_release_create`](.github/workflows/release.yml#L255-L268))
can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
@ -146,13 +146,15 @@ refusal is the single failure in this file that leaves a real release behind.
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
**no `v` prefix**, box's 0.6.0 set the scheme
([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the
([release.yml](.github/workflows/release.yml#L316-L401)) — publishes the
same way. The tag is the operator's explicit act, so there is no decide
and no label check — what is left is two asserts: **the tag names the
and no label check — what is left is three asserts: **the tag names the
tree's own version**
([L328L339](.github/workflows/release.yml#L328-L339)) and **the tagged
([L341L352](.github/workflows/release.yml#L341-L352)), **the tagged
tree carries a publishable `## X.Y.Z` section**
([L340L352](.github/workflows/release.yml#L340-L352)); either failing
([L353L365](.github/workflows/release.yml#L353-L365)), and **no published
release already exists for the tag**
([L366L381](.github/workflows/release.yml#L366-L381)); any failure
refuses, creating nothing. No `-dev` bump either
— the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the
@ -491,7 +493,7 @@ the thing twice. If the tag exists but the release does not (a manual tag
won the race, or
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
step itself failing after the tag), recover by the tag door: delete and
re-push the tag, or `gh release create` by hand from a fixed tree.
re-push the tag, or run `forge_release_create` by hand from a fixed tree.
> direct push refused (branch protection?) — opening the bump PR instead
@ -503,25 +505,31 @@ release and the
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open.
### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371))
### The tag door refused ([release.yml](.github/workflows/release.yml#L316-L401))
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
[L333L337](.github/workflows/release.yml#L333-L337). The message is the
[L347L350](.github/workflows/release.yml#L347-L350). The message is the
remedy.
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was
[L359L365](.github/workflows/release.yml#L359-L365). The tagged tree was
never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag.
> release '$VER' already exists — refusing to re-release, creating nothing.
[L366L381](.github/workflows/release.yml#L366-L381). A published release is
never replaced by the fallback. If it is correct, there is nothing to do; if
it is wrong, correct that published artifact deliberately before retrying.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301))
The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L303L307](.github/workflows/release.yml#L303-L307)) — and it
rewrite main ([L316L320](.github/workflows/release.yml#L316-L320)) — and it
runs *after* the tag, the notes and the publish. So a refusal here leaves a
real release standing behind a main that never re-armed — the release exists,
and main is left *armed to impersonate* it, still reading the version it just

3
changelog.d/271.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).

View file

@ -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 <sha> -> "[]", 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 <tag> — 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 <tag> — 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 <sha> — the pull requests whose merge produced <sha>, as
@ -765,20 +774,74 @@ forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri'
}
# forge_release_create <tag> <title> <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

View file

@ -952,16 +952,25 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
# tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> — a curl stub answering one canned response and
# recording the method+path it was asked for.
# release_stub <code> <body> [<code> <body>…] — a curl stub answering a
# sequence of canned responses and recording the method+path it was asked for.
# The cursor lives in a file because forge_release_create captures
# forgejo_write in a command substitution; an in-memory counter would advance
# only in that subshell and every later request would replay response one.
release_stub() {
# Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above.
STUB_CODE="$1" STUB_BODY="$2"
STUB_CODES=() STUB_BODIES=()
while [ "$#" -gt 0 ]; do
STUB_CODES+=("$1") STUB_BODIES+=("$2")
shift 2
done
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
printf '0\n' >"$TMP/release-stub-cursor"
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload=""
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
@ -969,18 +978,32 @@ release_stub() {
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;;
-f) fail_on_http=true ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
local index code body
if [ "$method" = GET ]; then
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
else
index="$(cat "$TMP/release-stub-cursor")"
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
fi
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
[ -n "$out" ] && printf '%s' "$body" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
if [ "$fail_on_http" = true ]; then
case "$code" in 2*) ;; *) return 22 ;; esac
fi
return 0
}
}
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL
@ -1023,6 +1046,10 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow
release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}'
@ -1046,9 +1073,22 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md"
release_stub 201 '{"id":42}'
release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
zero_asset_writes() {
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
local got
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
}
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
# Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
@ -1062,18 +1102,119 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}'
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the upload targets the created release id" 0 "releases/42/assets" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}'
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
happy_asset_writes() {
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
local got want
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
}
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
upload_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
flip_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
create_failure() {
release_stub 500 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
missing_release_id() {
release_stub 201 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
stranded_cleanup_failure() {
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
stranded_draft_order() {
local got
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
}
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
release_stub 409 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES"
# The tag door's published-release guard is extracted and executed, like the
# other workflow-shell contracts in this repository. The tag itself is this
# door's premise, so only a published release refuses; adding the merge door's
# ls-remote half here would destroy the documented delete-and-re-push recovery.
TAG_GUARD="$TMP/tag-release-guard.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$TAG_GUARD"
chmod +x "$TAG_GUARD"
mkdir -p "$TMP/tag-guard-lib/lib"
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/tag-guard-lib/lib/forge.sh"
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
run_tag_guard() {
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
}
tag_door_has_no_tag_exists_guard() {
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
}
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
# --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh"