forked from heavy-duty/ceremony
Merge pull request 'fix: publish Forgejo releases atomically' (#272) from build/271-atomic-forgejo-release into main
Reviewed-on: heavy-duty/ceremony#272 Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders> Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders> Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
This commit is contained in:
commit
58ab50361d
5 changed files with 285 additions and 46 deletions
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -363,6 +363,22 @@ jobs:
|
||||||
fi
|
fi
|
||||||
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
|
||||||
cat "$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
|
- name: artifact hook — the consumer's own release-artifact action
|
||||||
# After the tag exists (it fired this door), before the publish —
|
# After the tag exists (it fired this door), before the publish —
|
||||||
# the same contract as the merge door's twin step.
|
# the same contract as the merge door's twin step.
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -109,7 +109,7 @@ consumer's release, when it is stale.)
|
||||||
|
|
||||||
**The merge is the ship decision; the tag is transcription.** After the
|
**The merge is the ship decision; the tag is transcription.** After the
|
||||||
merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its
|
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 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
|
the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
|
||||||
canonical extractor, and [bin/changelog-section](bin/changelog-section) is
|
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
|
sorts them. Two fail before the release exists: the consumer's
|
||||||
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
|
[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
|
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
|
can fail on the API call or the assets. Either leaves the same state — a tag
|
||||||
standing and no release — which the
|
standing and no release — which the
|
||||||
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag
|
[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
|
- **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
|
— **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
|
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**
|
tree's own version**
|
||||||
([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged
|
([L341–L352](.github/workflows/release.yml#L341-L352)), **the tagged
|
||||||
tree carries a publishable `## X.Y.Z` section**
|
tree carries a publishable `## X.Y.Z` section**
|
||||||
([L340–L352](.github/workflows/release.yml#L340-L352)); either failing
|
([L353–L365](.github/workflows/release.yml#L353-L365)), and **no published
|
||||||
|
release already exists for the tag**
|
||||||
|
([L366–L381](.github/workflows/release.yml#L366-L381)); any failure
|
||||||
refuses, creating nothing. No `-dev` bump either
|
refuses, creating nothing. No `-dev` bump either
|
||||||
— the fallback does not rewrite main (cast's precedent). Use it when the
|
— the fallback does not rewrite main (cast's precedent). Use it when the
|
||||||
merge path is red, for backfills, and for 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
|
won the race, or
|
||||||
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish
|
[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
|
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
|
> 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
|
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
|
||||||
open.
|
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.
|
> 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.
|
> 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.
|
||||||
|
|
||||||
[L333–L337](.github/workflows/release.yml#L333-L337). The message is the
|
[L347–L350](.github/workflows/release.yml#L347-L350). The message is the
|
||||||
remedy.
|
remedy.
|
||||||
|
|
||||||
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
|
> CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
|
||||||
|
|
||||||
[L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was
|
[L359–L365](.github/workflows/release.yml#L359-L365). The tagged tree was
|
||||||
never stamped. Assemble the section
|
never stamped. Assemble the section
|
||||||
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
|
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
|
||||||
delete and re-push the tag.
|
delete and re-push the tag.
|
||||||
|
|
||||||
|
> release '$VER' already exists — refusing to re-release, creating nothing.
|
||||||
|
|
||||||
|
[L366–L381](.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 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
|
The bump belongs to the merge door alone — the tag door deliberately does not
|
||||||
rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it
|
rewrite main ([L316–L320](.github/workflows/release.yml#L316-L320)) — and it
|
||||||
runs *after* the tag, the notes and the publish. So a refusal here leaves a
|
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,
|
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
|
and main is left *armed to impersonate* it, still reading the version it just
|
||||||
|
|
|
||||||
3
changelog.d/271.md
Normal file
3
changelog.d/271.md
Normal 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).
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -952,16 +952,25 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
|
||||||
# tag creation GitHub POST /git/refs
|
# tag creation GitHub POST /git/refs
|
||||||
# Forgejo POST /tags (/git/refs is GET-only)
|
# Forgejo POST /tags (/git/refs is GET-only)
|
||||||
|
|
||||||
# release_stub <code> <body> — a curl stub answering one canned response and
|
# release_stub <code> <body> [<code> <body>…] — a curl stub answering a
|
||||||
# recording the method+path it was asked for.
|
# 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() {
|
release_stub() {
|
||||||
# Globals, not locals: the curl closure below runs long after this
|
# Globals, not locals: the curl closure below runs long after this
|
||||||
# function returns, exactly as stub_writes does above.
|
# 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"
|
: >"$WRITES"
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||||
curl() {
|
curl() {
|
||||||
local hdr="" out="" method=GET url="" payload=""
|
local hdr="" out="" method=GET url="" payload="" fail_on_http=false
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-D) hdr="$2"; shift ;;
|
-D) hdr="$2"; shift ;;
|
||||||
|
|
@ -969,18 +978,32 @@ release_stub() {
|
||||||
-X) method="$2"; shift ;;
|
-X) method="$2"; shift ;;
|
||||||
-d) payload="$2"; shift ;;
|
-d) payload="$2"; shift ;;
|
||||||
-F) payload="$payload -F $2"; shift ;;
|
-F) payload="$payload -F $2"; shift ;;
|
||||||
|
-f) fail_on_http=true ;;
|
||||||
-H) shift ;;
|
-H) shift ;;
|
||||||
-*) ;;
|
-*) ;;
|
||||||
*) url="$1" ;;
|
*) url="$1" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
|
local index code body
|
||||||
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
|
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"
|
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
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
|
||||||
|
|
||||||
GITHUB_API_URL=https://forge.example/api/v1
|
GITHUB_API_URL=https://forge.example/api/v1
|
||||||
export GITHUB_API_URL
|
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"}'
|
release_stub 200 '{"tag_name":"1.2.3"}'
|
||||||
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 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 '{}'
|
release_stub 404 '{}'
|
||||||
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
|
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
|
||||||
release_stub 503 '{}'
|
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
|
writes_after forge_tag_create 1.2.3 cafebabe
|
||||||
|
|
||||||
printf 'notes body\n' >"$TMP/notes.md"
|
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' \
|
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
|
# Assets: the hook contract permits any filename the consumer drops in
|
||||||
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
|
# 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 'x\n' >"$TMP/release asset.tgz"
|
||||||
printf 'y\n' >"$TMP/a&b.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" \
|
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_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
||||||
release_stub 201 '{"id":42}'
|
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" \
|
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_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||||
release_stub 201 '{"id":42}'
|
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||||
|
release_read 404 '{}'
|
||||||
check "the upload targets the created release id" 0 "releases/42/assets" \
|
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_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
||||||
release_stub 201 '{"id":42}'
|
release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
|
||||||
|
release_read 404 '{}'
|
||||||
check "the asset rides as a multipart attachment" 0 "attachment=@" \
|
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 ----------------------------
|
# --- the github twins address their own paths ----------------------------
|
||||||
. "$ROOT/lib/forge-github.sh"
|
. "$ROOT/lib/forge-github.sh"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue