forked from heavy-duty/ceremony
fix(forge): the release doors speak the shim, and an unread fact refuses (#191)
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.
Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:
* GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
emit the array shape, so facts.sh carries one jq expression.
* GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
GET-only and creates tags at /tags. A 1:1 port of the gh call would
have 404'd forever.
The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.
Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7fc9afe45f
commit
957f72739d
7 changed files with 387 additions and 47 deletions
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
|
@ -54,7 +54,7 @@ name: release
|
||||||
# branches: [main]
|
# branches: [main]
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: write # tag ref create + release create + the bump push
|
# contents: write # tag ref create + release create + the bump push
|
||||||
# pull-requests: write # the label read; the bump-fallback `gh pr create`
|
# pull-requests: write # the label read; the bump-fallback PR
|
||||||
# issues: write # --label on that fallback PR rides the issues API
|
# issues: write # --label on that fallback PR rides the issues API
|
||||||
# jobs:
|
# jobs:
|
||||||
# release:
|
# release:
|
||||||
|
|
@ -87,7 +87,7 @@ name: release
|
||||||
# ## The artifact hook (#1 D4)
|
# ## The artifact hook (#1 D4)
|
||||||
#
|
#
|
||||||
# If the consumer carries .github/actions/release-artifact/action.yml, both
|
# If the consumer carries .github/actions/release-artifact/action.yml, both
|
||||||
# doors invoke it — after the tag exists, before `gh release create` — with
|
# doors invoke it — after the tag exists, before the publish — with
|
||||||
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
|
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
|
||||||
# drops there is uploaded as a release asset. Exit non-zero to abort the
|
# drops there is uploaded as a release asset. Exit non-zero to abort the
|
||||||
# release. No hook → no assets.
|
# release. No hook → no assets.
|
||||||
|
|
@ -217,7 +217,14 @@ jobs:
|
||||||
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
|
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
# 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
|
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -231,8 +238,10 @@ jobs:
|
||||||
# the tag door cannot double-fire off this tag — and this job is
|
# the tag door cannot double-fire off this tag — and this job is
|
||||||
# the only chance to publish (the sources' central comment).
|
# the only chance to publish (the sources' central comment).
|
||||||
run: |
|
run: |
|
||||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
# shellcheck source=/dev/null
|
||||||
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA"
|
. "$CEREMONY_DIR/lib/forge.sh"
|
||||||
|
forge_select ""
|
||||||
|
forge_tag_create "$VER" "$MERGE_SHA"
|
||||||
- name: artifact hook — the consumer's own release-artifact action
|
- name: artifact hook — the consumer's own release-artifact action
|
||||||
# Runs after the tag exists, before the publish (#1 D4). The local
|
# Runs after the tag exists, before the publish (#1 D4). The local
|
||||||
# path resolves in the consumer checkout at the workspace root —
|
# path resolves in the consumer checkout at the workspace root —
|
||||||
|
|
@ -253,9 +262,10 @@ jobs:
|
||||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||||
done
|
done
|
||||||
gh release create "$VER" --verify-tag --title "$VER" \
|
# shellcheck source=/dev/null
|
||||||
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
. "$CEREMONY_DIR/lib/forge.sh"
|
||||||
"${assets[@]}"
|
forge_select ""
|
||||||
|
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
|
||||||
# The post-release bump, folded into the release act (the sources'
|
# The post-release bump, folded into the release act (the sources'
|
||||||
# operator decision: a mechanical one-liner deserves no PR of its
|
# operator decision: a mechanical one-liner deserves no PR of its
|
||||||
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
|
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
|
||||||
|
|
@ -294,10 +304,13 @@ jobs:
|
||||||
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
||||||
git checkout -b "chore/bump-$next"
|
git checkout -b "chore/bump-$next"
|
||||||
git push origin "chore/bump-$next"
|
git push origin "chore/bump-$next"
|
||||||
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
# shellcheck source=/dev/null
|
||||||
--title "chore: bump main to $next" \
|
. "$CEREMONY_DIR/lib/forge.sh"
|
||||||
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
|
forge_select ""
|
||||||
--label release
|
forge_pr_create "chore/bump-$next" main \
|
||||||
|
"chore: bump main to $next" \
|
||||||
|
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
|
||||||
|
release
|
||||||
fi
|
fi
|
||||||
|
|
||||||
release-on-tag:
|
release-on-tag:
|
||||||
|
|
@ -366,6 +379,7 @@ jobs:
|
||||||
for f in "$RELEASE_ASSETS_DIR"/*; do
|
for f in "$RELEASE_ASSETS_DIR"/*; do
|
||||||
if [ -e "$f" ]; then assets+=("$f"); fi
|
if [ -e "$f" ]; then assets+=("$f"); fi
|
||||||
done
|
done
|
||||||
gh release create "$VER" --verify-tag --title "$VER" \
|
# shellcheck source=/dev/null
|
||||||
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \
|
. "$CEREMONY_DIR/lib/forge.sh"
|
||||||
"${assets[@]}"
|
forge_select ""
|
||||||
|
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
|
||||||
|
|
|
||||||
20
changelog.d/191.md
Normal file
20
changelog.d/191.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
|
||||||
|
`release.yml` gathered and published through `gh`, which the runner image
|
||||||
|
does not ship, so the merge door read `labeled=no` for a correctly labeled
|
||||||
|
ceremony PR and the tag door died at the publish (#191).
|
||||||
|
|
||||||
|
- A release fact that could not be read is no longer reported as a definite
|
||||||
|
`no`. A completed read finding no label is still `no` and still
|
||||||
|
fail-closed; a read that did not complete refuses and emits no fact — the
|
||||||
|
distinction that demoted a ceremony PR to "a bare push" (#191).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
|
||||||
|
`forge_release_create` and `forge_pr_create` on both backends, so the
|
||||||
|
release path names no client. Forgejo serves one PR object at
|
||||||
|
`/commits/{sha}/pull` where GitHub serves an array at `/pulls`, and
|
||||||
|
creates tags at `/tags` where GitHub POSTs to `/git/refs`; both verbs emit
|
||||||
|
the GitHub shape so the call sites carry one expression (#191).
|
||||||
55
lib/facts.sh
55
lib/facts.sh
|
|
@ -4,7 +4,7 @@
|
||||||
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
|
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
|
||||||
# 5-state verdict. This script is the impure half that establishes those
|
# 5-state verdict. This script is the impure half that establishes those
|
||||||
# facts. It runs inside the consumer's checkout (the working directory),
|
# facts. It runs inside the consumer's checkout (the working directory),
|
||||||
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form:
|
# talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
|
||||||
#
|
#
|
||||||
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
|
||||||
#
|
#
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
# MERGE_SHA the pushed head (github.sha)
|
# MERGE_SHA the pushed head (github.sha)
|
||||||
# EVENT_BEFORE github.event.before — may be empty or all-zeros
|
# EVENT_BEFORE github.event.before — may be empty or all-zeros
|
||||||
# GITHUB_REPOSITORY for the two API facts
|
# GITHUB_REPOSITORY for the two API facts
|
||||||
# GH_TOKEN for gh (unused when no API state is consulted)
|
# GH_TOKEN for the forge client (unused when no API state is
|
||||||
|
# consulted)
|
||||||
#
|
#
|
||||||
# The API calls run only in the states that consult them (decide tolerates
|
# The API calls run only in the states that consult them (decide tolerates
|
||||||
# empty facts — issue #8): RELEASED only for a bare unchanged version,
|
# empty facts — issue #8): RELEASED only for a bare unchanged version,
|
||||||
|
|
@ -24,8 +25,11 @@
|
||||||
# decides on the two versions alone and never touches the API.
|
# decides on the two versions alone and never touches the API.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck source=lib/version.sh
|
# shellcheck source=lib/version.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
|
. "$_facts_lib/version.sh"
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$_facts_lib/forge.sh"
|
||||||
|
|
||||||
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
|
||||||
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
: "${MERGE_SHA:?facts: MERGE_SHA is required}"
|
||||||
|
|
@ -94,25 +98,38 @@ fi
|
||||||
released=""
|
released=""
|
||||||
labeled=""
|
labeled=""
|
||||||
if ! version_is_dev "$ver"; then
|
if ! version_is_dev "$ver"; then
|
||||||
|
# The forge is selected only in the states that consult the API — a -dev
|
||||||
|
# tree, every ordinary merge, still decides on the two versions alone and
|
||||||
|
# touches no forge at all (#8's tolerance for empty facts).
|
||||||
|
# "" means decide from the environment; forge_select takes an explicit
|
||||||
|
# forge only in tests.
|
||||||
|
forge_select "" || exit 1
|
||||||
|
|
||||||
if [ "$base_ver" = "$ver" ]; then
|
if [ "$base_ver" = "$ver" ]; then
|
||||||
# Any gh failure reads as "not released" — the sources' semantics; the
|
# Row 4's input. Before #191 any failure here read as "not released",
|
||||||
# verdict this feeds (row 4) is a refusal, and the ceremony path
|
# which is safe only because row 4 refuses either way. It is still a
|
||||||
# re-checks existence in the nothing-exists assert before creating
|
# lie about what was observed, so an unreadable answer refuses.
|
||||||
# anything.
|
if ! released="$(forge_release_exists "$ver")"; then
|
||||||
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then
|
echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
|
||||||
released=yes
|
exit 1
|
||||||
else
|
|
||||||
released=no
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# The sources' exact jq: merged PRs only, `release` among the label
|
# Row 5's input, and the one that cost a release: a push event carries
|
||||||
# names. Read via the API because a push event carries no PR payload —
|
# no PR payload, so the label is read from the API. The old code turned
|
||||||
# and the PR itself lives on a fork (the trigger comment in the
|
# ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
|
||||||
# workflow). A failed API call reads as "no label", which row 5
|
# demoted a correctly labeled, correctly merged ceremony PR into "a bare
|
||||||
# refuses: fail-closed.
|
# push", refusing the release and creating nothing. Measured in the
|
||||||
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
|
# 0.4.1 drill, twice (drills/0.4.1.md).
|
||||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \
|
#
|
||||||
| grep -qx true; then
|
# Now: a completed read that finds no merged release-labeled PR is still
|
||||||
|
# `no` and still fail-closed. A read that did not complete refuses.
|
||||||
|
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
|
||||||
|
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# One jq expression for both forges: the backends agree on the shape.
|
||||||
|
if printf '%s' "$pulls" \
|
||||||
|
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
|
||||||
labeled=yes
|
labeled=yes
|
||||||
else
|
else
|
||||||
labeled=no
|
labeled=no
|
||||||
|
|
|
||||||
|
|
@ -481,3 +481,157 @@ forge_pr_activity() {
|
||||||
--jq '.[].created_at' || return 1
|
--jq '.[].created_at' || return 1
|
||||||
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- the release door's facts (#191) --------------------------------------
|
||||||
|
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
|
||||||
|
# both distinguish "the read completed and the answer is no" from "the read
|
||||||
|
# did not complete" — the distinction lib/facts.sh got wrong before #191,
|
||||||
|
# where any failure became a definite `no` and a release ceremony was
|
||||||
|
# silently demoted to a bare push.
|
||||||
|
#
|
||||||
|
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
|
||||||
|
#
|
||||||
|
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
|
||||||
|
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
|
||||||
|
#
|
||||||
|
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
|
||||||
|
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
|
||||||
|
# ...on a commit with no PR -> 404 {"message":"pull request
|
||||||
|
# does not exist …"}
|
||||||
|
#
|
||||||
|
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
|
||||||
|
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
|
||||||
|
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
|
||||||
|
# for both forges, which is the whole point of the shim.
|
||||||
|
|
||||||
|
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
|
||||||
|
# status on stdout. Separate from forge_api because these two call sites
|
||||||
|
# must SEE a 404 rather than have it collapsed into a failure.
|
||||||
|
forgejo_read_code() {
|
||||||
|
local endpoint="$1" body="$2" base token hdr rc
|
||||||
|
base="$(forgejo_api_base)" || return 1
|
||||||
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
|
hdr="$(mktemp)"
|
||||||
|
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
rm -f "$hdr"
|
||||||
|
echo "forge: GET $endpoint failed to send" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
|
||||||
|
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() {
|
||||||
|
local tag="${1:?forge_release_exists: tag required}" body code
|
||||||
|
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 ;;
|
||||||
|
404) echo no ;;
|
||||||
|
*)
|
||||||
|
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||||
|
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
|
||||||
|
# found nothing; a non-zero exit is a read that did not complete.
|
||||||
|
forge_commit_pulls() {
|
||||||
|
local sha="${1:?forge_commit_pulls: sha required}" body code out
|
||||||
|
body="$(mktemp)"
|
||||||
|
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
|
||||||
|
case "$code" in
|
||||||
|
2*)
|
||||||
|
# One object -> a one-element array, so the call site's jq is the
|
||||||
|
# same expression it runs against GitHub.
|
||||||
|
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
|
||||||
|
rm -f "$body"
|
||||||
|
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
;;
|
||||||
|
404) printf '[]\n' ;;
|
||||||
|
*)
|
||||||
|
rm -f "$body"
|
||||||
|
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -f "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the release door's writes (#191) -------------------------------------
|
||||||
|
# Confirmed against this instance's own swagger, 2026-08-04:
|
||||||
|
#
|
||||||
|
# POST /repos/{o}/{r}/tags -> exists (tag creation)
|
||||||
|
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
|
||||||
|
# POST /repos/{o}/{r}/releases -> exists
|
||||||
|
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
|
||||||
|
#
|
||||||
|
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
|
||||||
|
# /git/refs; Forgejo does not serve POST there at all and creates tags at
|
||||||
|
# /tags instead. A 1:1 port of the gh call would 404 forever.
|
||||||
|
|
||||||
|
# forge_tag_create <tag> <sha>
|
||||||
|
forge_tag_create() {
|
||||||
|
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
||||||
|
forgejo_write POST "repos/$REPO/tags" \
|
||||||
|
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
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
|
||||||
|
shift 3
|
||||||
|
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
|
||||||
|
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
|
||||||
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
|
local f
|
||||||
|
for f in "$@"; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
curl -sS -f -X POST -H "Authorization: token $token" \
|
||||||
|
-F "attachment=@$f" \
|
||||||
|
"$base/repos/$REPO/releases/$id/assets?name=$(basename "$f")" >/dev/null \
|
||||||
|
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
||||||
|
# label IDs, not names (the same asymmetry the issue-label writes carry), so
|
||||||
|
# the names are resolved first through forgejo_label_ids.
|
||||||
|
forge_pr_create() {
|
||||||
|
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
||||||
|
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
||||||
|
shift 4
|
||||||
|
local ids='[]' map name id
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
map="$(forgejo_label_ids)" || return 1
|
||||||
|
ids='['
|
||||||
|
for name in "$@"; do
|
||||||
|
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
|
||||||
|
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
|
||||||
|
ids="$ids$id,"
|
||||||
|
done
|
||||||
|
ids="${ids%,}]"
|
||||||
|
fi
|
||||||
|
forgejo_write POST "repos/$REPO/pulls" \
|
||||||
|
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
|
||||||
|
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,80 @@ forge_pr_activity() {
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
|
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
|
||||||
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- the release door's facts (#191) --------------------------------------
|
||||||
|
# The github twins of the forgejo backend's two release-door reads. Term 5
|
||||||
|
# discipline applies: these are the `gh` calls lib/facts.sh carried before
|
||||||
|
# the port, with one behaviour added — a read that did not complete is
|
||||||
|
# reported as such instead of collapsing into a definite `no`.
|
||||||
|
|
||||||
|
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
|
||||||
|
# read did not complete and the answer is UNKNOWN (#191).
|
||||||
|
forge_release_exists() {
|
||||||
|
local tag="${1:?forge_release_exists: tag required}" errf err rc
|
||||||
|
errf="$(mktemp)"
|
||||||
|
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
|
||||||
|
rm -f "$errf"
|
||||||
|
echo yes
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
err="$(cat "$errf")"; rm -f "$errf"
|
||||||
|
# gh's 404 text is stable and is the only failure that is an ANSWER.
|
||||||
|
case "$err" in
|
||||||
|
*"HTTP 404"*) echo no; return 0 ;;
|
||||||
|
esac
|
||||||
|
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
||||||
|
# a JSON array. GitHub serves the array directly; the forgejo twin builds
|
||||||
|
# one from its single-object endpoint so this call site is identical.
|
||||||
|
forge_commit_pulls() {
|
||||||
|
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
|
||||||
|
errf="$(mktemp)"
|
||||||
|
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
|
||||||
|
rm -f "$errf"
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
err="$(cat "$errf")"; rm -f "$errf"
|
||||||
|
case "$err" in
|
||||||
|
*"HTTP 404"*) printf '[]\n'; return 0 ;;
|
||||||
|
esac
|
||||||
|
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the release door's writes (#191) -------------------------------------
|
||||||
|
# The gh calls the workflow carried before the port, moved behind the shim
|
||||||
|
# so the call sites stop naming a client. Term 5: same flags, same order.
|
||||||
|
|
||||||
|
# forge_tag_create <tag> <sha>
|
||||||
|
forge_tag_create() {
|
||||||
|
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
||||||
|
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_release_create <tag> <title> <notes-file> [asset…]
|
||||||
|
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}"
|
||||||
|
shift 3
|
||||||
|
gh release create "$tag" --verify-tag --title "$title" \
|
||||||
|
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
|
||||||
|
# bump-fallback PR (#191). gh takes repeated --label flags.
|
||||||
|
forge_pr_create() {
|
||||||
|
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
||||||
|
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
||||||
|
shift 4
|
||||||
|
local args=() l
|
||||||
|
for l in "$@"; do args+=(--label "$l"); done
|
||||||
|
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
|
||||||
|
--title "$title" --body "$body" "${args[@]}"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,20 +29,39 @@ ZEROS="0000000000000000000000000000000000000000"
|
||||||
mkdir -p "$TMP/stub"
|
mkdir -p "$TMP/stub"
|
||||||
cat >"$TMP/stub/gh" <<'EOF'
|
cat >"$TMP/stub/gh" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Every mode below answers the call shape the shim now makes (#191):
|
||||||
|
# labeled -> gh api repos/{r}/commits/{sha}/pulls (a JSON ARRAY)
|
||||||
|
# released -> gh api repos/{r}/releases/tags/{tag}
|
||||||
|
# The *-unreadable modes are the ones that matter: they fail the way a real
|
||||||
|
# client fails when it cannot reach the forge, and must NOT be reported as a
|
||||||
|
# definite answer.
|
||||||
case "${GH_STUB:-none}" in
|
case "${GH_STUB:-none}" in
|
||||||
labeled-yes | labeled-no)
|
labeled-yes)
|
||||||
if [ "$1" != api ]; then
|
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
||||||
echo "gh stub: expected an api call, got: gh $*" >&2
|
|
||||||
exit 97
|
|
||||||
fi
|
|
||||||
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
|
|
||||||
;;
|
;;
|
||||||
released-yes | released-no)
|
labeled-no)
|
||||||
if [ "$1" != release ]; then
|
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]'
|
||||||
echo "gh stub: expected a release call, got: gh $*" >&2
|
;;
|
||||||
exit 97
|
labeled-none)
|
||||||
fi
|
# A completed read that found no PR at all — still an answer.
|
||||||
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1
|
echo '[]'
|
||||||
|
;;
|
||||||
|
labeled-unmerged)
|
||||||
|
# A PR carrying the label but never merged: the label alone is not a
|
||||||
|
# ceremony (the `merged_at != null` half of the contract).
|
||||||
|
echo '[{"merged_at":null,"labels":[{"name":"release"}]}]'
|
||||||
|
;;
|
||||||
|
labeled-unreadable | released-unreadable)
|
||||||
|
echo "gh: Connection refused (HTTP 000)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
released-yes)
|
||||||
|
echo "$2" | grep -q 'releases/tags/' || { echo "gh stub: expected a releases/tags read, got: gh $*" >&2; exit 97; }
|
||||||
|
echo "0.0.0"
|
||||||
|
;;
|
||||||
|
released-no)
|
||||||
|
echo "gh: Not Found (HTTP 404)" >&2
|
||||||
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
echo "gh stub: gh must not be called in this state (gh $*)" >&2
|
||||||
|
|
@ -74,6 +93,7 @@ facts_in() {
|
||||||
shift
|
shift
|
||||||
(cd "$TMP/$dir" \
|
(cd "$TMP/$dir" \
|
||||||
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
|
||||||
|
CEREMONY_FORGE=github \
|
||||||
"$@" bash "$FACTS")
|
"$@" bash "$FACTS")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,4 +234,37 @@ nv_head="$(commit no-version README.md "with no version at the head either")"
|
||||||
check "no version at the head fails loudly" 1 "no such file" \
|
check "no version at the head fails loudly" 1 "no such file" \
|
||||||
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
|
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
|
||||||
|
|
||||||
|
# --- #191: a read that did not complete is not an answer ------------------
|
||||||
|
# The bug this suite missed before: lib/facts.sh turned ANY failure of the
|
||||||
|
# label read into `labeled=no`, and decide's row 5 then refused a correctly
|
||||||
|
# labeled, correctly merged ceremony PR as "a bare push". On a Forgejo
|
||||||
|
# runner — no `gh` on the image — that was every release. Measured twice in
|
||||||
|
# the 0.4.1 drill (drills/0.4.1.md) before it was fixed.
|
||||||
|
#
|
||||||
|
# The contract now: a COMPLETED read that finds nothing is still `no` and
|
||||||
|
# still fail-closed; a read that could not complete refuses, loudly, and
|
||||||
|
# emits no fact at all.
|
||||||
|
|
||||||
|
check "a completed read with no PR behind the commit is labeled=no" 0 "labeled=no" \
|
||||||
|
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-none
|
||||||
|
check "a labeled but UNMERGED PR is labeled=no" 0 "labeled=no" \
|
||||||
|
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unmerged
|
||||||
|
|
||||||
|
check "an unreadable label read refuses instead of saying no" 1 "refusing rather than reporting 'no label'" \
|
||||||
|
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
||||||
|
# ...and emits no fact: a refusal that still printed labeled=no would be the
|
||||||
|
# same bug wearing a diagnostic.
|
||||||
|
check "the refusal emits no labeled fact at all" 1 "" \
|
||||||
|
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
|
||||||
|
if facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable 2>/dev/null | grep -q '^labeled='; then
|
||||||
|
echo "FAIL: the refusal printed a labeled= line" >&2
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
else
|
||||||
|
echo "ok: no labeled= line survives the refusal"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
check "an unreadable release read refuses instead of saying no" 1 "refusing rather than reporting 'no'" \
|
||||||
|
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-unreadable
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ trap 'rm -rf "$TMP"' EXIT
|
||||||
mkdir -p "$TMP/stub"
|
mkdir -p "$TMP/stub"
|
||||||
cat >"$TMP/stub/gh" <<'EOF'
|
cat >"$TMP/stub/gh" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
if [ "$1" = api ]; then echo true; exit 0; fi
|
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
|
||||||
|
if [ "$1" = api ]; then
|
||||||
|
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
echo "gh stub: unexpected call: gh $*" >&2
|
echo "gh stub: unexpected call: gh $*" >&2
|
||||||
exit 97
|
exit 97
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -83,6 +87,7 @@ chain() {
|
||||||
(
|
(
|
||||||
cd "${3:-$TMP/repo}" || exit 1
|
cd "${3:-$TMP/repo}" || exit 1
|
||||||
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
|
||||||
|
CEREMONY_FORGE=github \
|
||||||
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
|
||||||
bash "$FACTS")" || exit 1
|
bash "$FACTS")" || exit 1
|
||||||
printf '%s\n' "$facts_out"
|
printf '%s\n' "$facts_out"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue