From 957f72739db1da941fbf9bc2461a9721a24a9ff8 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Tue, 4 Aug 2026 11:31:06 +0000 Subject: [PATCH 1/7] fix(forge): the release doors speak the shim, and an unread fact refuses (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/release.yml | 44 ++++++---- changelog.d/191.md | 20 +++++ lib/facts.sh | 55 +++++++----- lib/forge-forgejo.sh | 154 ++++++++++++++++++++++++++++++++++ lib/forge-github.sh | 77 +++++++++++++++++ test/facts.test.sh | 77 ++++++++++++++--- test/release-chain.test.sh | 7 +- 7 files changed, 387 insertions(+), 47 deletions(-) create mode 100644 changelog.d/191.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d604f0e..556b841 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ name: release # branches: [main] # permissions: # 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 # jobs: # release: @@ -87,7 +87,7 @@ name: release # ## The artifact hook (#1 D4) # # 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 # drops there is uploaded as a release asset. Exit non-zero to abort the # 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 exit 1 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 exit 1 fi @@ -231,8 +238,10 @@ jobs: # the tag door cannot double-fire off this tag — and this job is # the only chance to publish (the sources' central comment). run: | - gh api "repos/$GITHUB_REPOSITORY/git/refs" \ - -f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA" + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/forge.sh" + forge_select "" + forge_tag_create "$VER" "$MERGE_SHA" - name: artifact hook — the consumer's own release-artifact action # Runs after the tag exists, before the publish (#1 D4). The local # path resolves in the consumer checkout at the workspace root — @@ -253,9 +262,10 @@ jobs: for f in "$RELEASE_ASSETS_DIR"/*; do if [ -e "$f" ]; then assets+=("$f"); fi done - gh release create "$VER" --verify-tag --title "$VER" \ - --notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \ - "${assets[@]}" + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/forge.sh" + forge_select "" + forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}" # The post-release bump, folded into the release act (the sources' # operator decision: a mechanical one-liner deserves no PR of its # 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 git checkout -b "chore/bump-$next" git push origin "chore/bump-$next" - gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ - --title "chore: bump main to $next" \ - --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." \ - --label release + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/forge.sh" + forge_select "" + 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 release-on-tag: @@ -366,6 +379,7 @@ jobs: for f in "$RELEASE_ASSETS_DIR"/*; do if [ -e "$f" ]; then assets+=("$f"); fi done - gh release create "$VER" --verify-tag --title "$VER" \ - --notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \ - "${assets[@]}" + # shellcheck source=/dev/null + . "$CEREMONY_DIR/lib/forge.sh" + forge_select "" + forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}" diff --git a/changelog.d/191.md b/changelog.d/191.md new file mode 100644 index 0000000..2e78682 --- /dev/null +++ b/changelog.d/191.md @@ -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). diff --git a/lib/facts.sh b/lib/facts.sh index add259b..5c5ceda 100644 --- a/lib/facts.sh +++ b/lib/facts.sh @@ -4,7 +4,7 @@ # 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 # 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) # @@ -16,7 +16,8 @@ # MERGE_SHA the pushed head (github.sha) # EVENT_BEFORE github.event.before — may be empty or all-zeros # 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 # 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. set -euo pipefail +_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 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}" : "${MERGE_SHA:?facts: MERGE_SHA is required}" @@ -94,25 +98,38 @@ fi released="" labeled="" 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 - # Any gh failure reads as "not released" — the sources' semantics; the - # verdict this feeds (row 4) is a refusal, and the ceremony path - # re-checks existence in the nothing-exists assert before creating - # anything. - if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then - released=yes - else - released=no + # Row 4's input. Before #191 any failure here read as "not released", + # which is safe only because row 4 refuses either way. It is still a + # lie about what was observed, so an unreadable answer refuses. + if ! released="$(forge_release_exists "$ver")"; then + echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2 + exit 1 fi else - # The sources' exact jq: merged PRs only, `release` among the label - # names. Read via the API because a push event carries no PR payload — - # and the PR itself lives on a fork (the trigger comment in the - # workflow). A failed API call reads as "no label", which row 5 - # refuses: fail-closed. - if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \ - -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \ - | grep -qx true; then + # Row 5's input, and the one that cost a release: a push event carries + # no PR payload, so the label is read from the API. The old code turned + # ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that + # demoted a correctly labeled, correctly merged ceremony PR into "a bare + # push", refusing the release and creating nothing. Measured in the + # 0.4.1 drill, twice (drills/0.4.1.md). + # + # 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 else labeled=no diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 1aae741..94915f1 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -481,3 +481,157 @@ forge_pr_activity() { --jq '.[].created_at' || return 1 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 — 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 — 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 — the pull requests whose merge produced , 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 +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 <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 +} diff --git a/lib/forge-github.sh b/lib/forge-github.sh index c3e9351..36307e1 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -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/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[@]}" +} diff --git a/test/facts.test.sh b/test/facts.test.sh index ad2a9e3..b1387b2 100644 --- a/test/facts.test.sh +++ b/test/facts.test.sh @@ -29,20 +29,39 @@ ZEROS="0000000000000000000000000000000000000000" mkdir -p "$TMP/stub" cat >"$TMP/stub/gh" <<'EOF' #!/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 - labeled-yes | labeled-no) - if [ "$1" != api ]; then - echo "gh stub: expected an api call, got: gh $*" >&2 - exit 97 - fi - [ "${GH_STUB}" = labeled-yes ] && echo true || echo false + labeled-yes) + echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]' ;; - released-yes | released-no) - if [ "$1" != release ]; then - echo "gh stub: expected a release call, got: gh $*" >&2 - exit 97 - fi - [ "${GH_STUB}" = released-yes ] && exit 0 || exit 1 + labeled-no) + echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]' + ;; + labeled-none) + # A completed read that found no PR at all — still an answer. + 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 @@ -74,6 +93,7 @@ facts_in() { shift (cd "$TMP/$dir" \ && env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \ + CEREMONY_FORGE=github \ "$@" 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" \ 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 diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index 3633824..e6826f8 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -29,7 +29,11 @@ trap 'rm -rf "$TMP"' EXIT mkdir -p "$TMP/stub" cat >"$TMP/stub/gh" <<'EOF' #!/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 exit 97 EOF @@ -83,6 +87,7 @@ chain() { ( cd "${3:-$TMP/repo}" || exit 1 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" \ bash "$FACTS")" || exit 1 printf '%s\n' "$facts_out" From 87cc7d5aa5efa8cdf4cf46afeb2404f20b48523a Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 11:37:00 +0000 Subject: [PATCH 2/7] fix(forge): facts.sh must set REPO, and the fragment must fit the bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures on !193's first run, both real and both caught by the guards that exist for them. release-exercise: `lib/forge-forgejo.sh: line 550: REPO: unbound variable`. The forgejo backend addresses the repository through REPO, which each reconciler sets for itself; the github backend reads GITHUB_REPOSITORY directly. facts.sh set neither, so every forgejo read refused — correctly, and with the new #191 diagnostic, which is how it was legible at all. The github-path suites could not have caught this: they never touch that backend. self-guards: changelog-armed measured a 404-character entry against the 300-character bound (#167). Split into three shorter entries in the same fragment, which is what the rule asks for. Refs #191 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- changelog.d/191.md | 20 ++++++++++++-------- lib/facts.sh | 7 +++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/changelog.d/191.md b/changelog.d/191.md index 2e78682..0659f81 100644 --- a/changelog.d/191.md +++ b/changelog.d/191.md @@ -2,19 +2,23 @@ - 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). + 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). + fail-closed; a read that did not complete refuses and emits no fact + (#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). + release path names no client (#191). + +- The forgejo backend serves one PR object at `/commits/{sha}/pull` where + GitHub serves an array at `/pulls`; both verbs emit the array shape, so + the call site carries one expression (#191). + +- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only, + so GitHub's ref-POST would have 404'd there forever (#191). diff --git a/lib/facts.sh b/lib/facts.sh index 5c5ceda..66fbca7 100644 --- a/lib/facts.sh +++ b/lib/facts.sh @@ -101,6 +101,13 @@ 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). + # The forgejo backend addresses the repo through REPO; the github backend + # reads GITHUB_REPOSITORY directly. Set it here from the one this script + # already documents, so the two backends address the same repository — + # missing it made every forgejo read refuse with "REPO: unbound variable" + # (caught by release-exercise on !193). + REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}" + export REPO # "" means decide from the environment; forge_select takes an explicit # forge only in tests. forge_select "" || exit 1 From c63a55067e203607eff81dbe60e2bb8ca029785f Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 11:42:28 +0000 Subject: [PATCH 3/7] fix(exercise): pin the rehearsal to the backend its stub speaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-exercise stubs `gh` to answer the one API fact the ceremony path consults. Since #191 facts.sh selects a backend, and on this Forgejo runner it selected the forgejo one — which speaks curl and walked straight past the stub to the live instance, read the fixture's SHA against the real repository, found no merged release-labeled PR behind it and refused. The exercise rehearses the WIRING — facts → decide → notes through the real $GITHUB_OUTPUT plumbing. Which backend answers is lib/forge.sh's own contract and is covered in test/forge*.test.sh. So the facts step now pins CEREMONY_FORGE=github, the backend its stub is shaped for, and the stub returns the array shape the new label read expects. Refs #191 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/release-exercise.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index 90201c4..aa83b37 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -94,12 +94,25 @@ jobs: # consumes the tool end to end instead of hand-writing its output. # Same shape as test/release-chain.test.sh. The gh stub answers the # one API fact the ceremony path consults (the merged - # release-labeled PR) so nothing here talks to GitHub. + # release-labeled PR) so nothing here talks to a forge. + # + # The stub is gh-shaped, so the facts step below pins + # CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and + # on a Forgejo runner it would otherwise pick the forgejo backend, + # which speaks curl and would walk straight past this stub to the + # real instance — reading the exercise's fixture SHA against the + # live repository and refusing it. The exercise rehearses the + # WIRING; which backend answers is lib/forge.sh's own contract, + # covered in test/forge*.test.sh. run: | mkdir -p "$RUNNER_TEMP/stub" cat > "$RUNNER_TEMP/stub/gh" <<'EOF' #!/usr/bin/env bash - if [ "$1" = api ]; then echo true; exit 0; fi + # The label read is 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 exit 97 EOF @@ -136,6 +149,9 @@ jobs: # back to the merge commit's first parent (#1 constraint 10). EVENT_BEFORE: "" VERSION_SOURCE: file + # The stub above is gh-shaped; pin the backend that uses it. + CEREMONY_FORGE: github + GITHUB_REPOSITORY: fixture/fixture # release.yml's step verbatim — same invocation, same # $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the # workspace (the one thing a replay cannot inherit). From 21c70e06a470270143385c9506dadce99feae991 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 11:53:28 +0000 Subject: [PATCH 4/7] fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both panel blockers on c63a550. @kimi found the one that mattered: facts.sh got the REPO fix, release.yml's own four call sites did not. A workflow `run:` shell carries no `set -u`, so an unset REPO expands empty and the verb addresses `repos//…` — which 404s, and the 404 is then read as an ANSWER. Reproduced read-only against this instance before fixing: forge_release_exists 0.4.1 -> "no", rc 0 forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a merged PR behind it) The first would have let the nothing-exists assert proceed to CREATE; the second is the drill's original fabricated `labeled=no`, one step after the fix meant to kill it. Fixed once rather than at four call sites, as kimi suggested: forge_select defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every verb reaches the network through — refuses an empty REPO outright. No fifth call site can forget it. @grok and @kimi both blocked on the same AC gap: the backend suite did not cover the five new verbs, so the two measured asymmetries had no offline coverage. test/forge-backends.test.sh now has 15 cases for them — singular /pull wrapped to an array, 404 as an empty array, 500 refusing, release present/absent/unreadable, POST /tags vs /git/refs, the publish body, and the REPO-empty must-fail. Mutation-checked: reading the plural path fails one case, dropping the REPO guard fails the two must-fails. 1029 assertions, 22 suites, shellcheck-all and actionlint clean. Refs #191 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- changelog.d/191.md | 4 ++ lib/forge-forgejo.sh | 24 ++++++++ lib/forge.sh | 6 ++ test/forge-backends.test.sh | 113 ++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) diff --git a/changelog.d/191.md b/changelog.d/191.md index 0659f81..89cf80f 100644 --- a/changelog.d/191.md +++ b/changelog.d/191.md @@ -22,3 +22,7 @@ - Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only, so GitHub's ref-POST would have 404'd there forever (#191). + +- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates + it and every call reaches the network through there, so `repos//…` — + whose 404 reads as "no release" and "no PRs" — is now impossible (#191). diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 94915f1..940751c 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -18,6 +18,30 @@ forgejo_api_base() { echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2 return 1 fi + # Every verb in this backend interpolates $REPO into its path, and every + # one of them reaches the network through here — so this is the one place + # that can make `repos//…` impossible. + # + # 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_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged + # PR behind it, read as none) + # + # A workflow `run:` shell carries no `set -u`, so an unset REPO expands + # empty and 404s into a fabricated fact instead of crashing. That is the + # exact failure #191 exists to remove, so it refuses here rather than + # anywhere later. + if [ -z "${REPO:-}" ]; then + echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2 + return 1 + fi printf '%s\n' "${base%/}" } diff --git a/lib/forge.sh b/lib/forge.sh index bcf1c26..eda3ffc 100644 --- a/lib/forge.sh +++ b/lib/forge.sh @@ -126,6 +126,12 @@ FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # environment decides via forge_detect. forge_select() { local forge="${1:-}" + # One default for every consumer: the forgejo backend addresses the repo + # through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting + # here means no call site — workflow step or script — can forget it and + # get a repo-less path (#191). The reconcilers still assert their own. + REPO="${REPO:-${GITHUB_REPOSITORY:-}}" + export REPO if [ -z "$forge" ]; then forge="$(forge_detect)" || return 1 fi diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index cc02d1f..2793231 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -44,6 +44,12 @@ check "select with no argument reads the environment" 0 "" \ . "$ROOT/lib/forge-github.sh" . "$ROOT/lib/forge-forgejo.sh" +# Every /api/v1 call ceremony makes is repo-scoped, and since #191 the +# backend refuses to build `repos//…` — so the suite names a repo up front, +# the way every real caller does. +REPO=o/r +export REPO + check "github: a bare path gets a query" 0 "" \ eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues' check "github: an existing query is preserved" 0 "" \ @@ -616,4 +622,111 @@ check "...and never fetches a zero-comment review" 1 "" \ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \ grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls" +# --- the release door's verbs, both backends (#191) ----------------------- +# The five verbs the release path now goes through. These carry two measured +# asymmetries that would 404 forever if wrong, and neither is visible to a +# github-only suite: +# +# PRs behind a commit GitHub GET /commits/{sha}/pulls -> ARRAY +# Forgejo GET /commits/{sha}/pull -> ONE OBJECT +# (the plural 404s) +# 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() { + # 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" + : >"$WRITES" + # shellcheck disable=SC2317 # invoked indirectly, by the forge verbs + curl() { + local hdr="" out="" method=GET url="" payload="" + while [ $# -gt 0 ]; do + case "$1" in + -D) hdr="$2"; shift ;; + -o) out="$2"; shift ;; + -X) method="$2"; shift ;; + -d) payload="$2"; shift ;; + -H | -F) 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" + printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" + return 0 + } +} + +GITHUB_API_URL=https://forge.example/api/v1 +export GITHUB_API_URL + +# Helpers so the assertions run in THIS shell, where the verbs are defined. +pulls_is_array() { forge_commit_pulls "$1" | jq -e 'type == "array" and length == 1' >/dev/null && echo array-of-1; } +writes_after() { "$@" >/dev/null 2>&1; cat "$WRITES"; } +repo_empty_release() { REPO='' forge_release_exists 1.2.3; } +repo_empty_pulls() { REPO='' forge_commit_pulls deadbeef; } + +release_stub 200 '{"number":7,"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}' +check "forgejo: one PR object becomes a one-element array" 0 '"number":7' \ + forge_commit_pulls deadbeef +check "forgejo: the array is what the call site's jq expects" 0 "array-of-1" \ + pulls_is_array deadbeef +check "forgejo: it reads the SINGULAR path" 0 "commits/deadbeef/pull " \ + writes_after forge_commit_pulls deadbeef + +release_stub 404 '{"message":"pull request does not exist"}' +check "forgejo: 404 is an empty array, not a failure" 0 "[]" forge_commit_pulls deadbeef + +release_stub 500 '{}' +check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknown, not 'none'" \ + forge_commit_pulls deadbeef + +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 404 '{}' +check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3 +release_stub 503 '{}' +check "forgejo: an unreadable release refuses, not 'no'" 1 "the answer is unknown, not 'no'" \ + forge_release_exists 1.2.3 + +# THE MUST-FAIL (#191, found by @kimi on !193 before it shipped): with REPO +# empty every path becomes repos//… , whose 404 would read as a fact — "no" +# and "[]" with rc 0. That is the bug this issue exists to remove. +release_stub 404 '{}' +check "REPO empty refuses instead of fabricating 'no'" 1 "refusing to address 'repos//" \ + repo_empty_release +check "REPO empty refuses instead of fabricating '[]'" 1 "refusing to address 'repos//" \ + repo_empty_pulls + +release_stub 201 '{"id":42}' +check "forgejo: a tag is created at /tags, not /git/refs" 0 "POST repos/o/r/tags" \ + writes_after forge_tag_create 1.2.3 cafebabe +release_stub 201 '{"id":42}' +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}' +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" + +# --- the github twins address their own paths ---------------------------- +. "$ROOT/lib/forge-github.sh" +GITHUB_REPOSITORY=o/r +export GITHUB_REPOSITORY +GH_CALLS="$TMP/ghcalls" +# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs +gh() { printf '%s\n' "$*" >>"$GH_CALLS"; case "$*" in *commits/*) echo '[]' ;; esac; return 0; } +gh_after() { : >"$GH_CALLS"; "$@" >/dev/null 2>&1; cat "$GH_CALLS"; } + +check "github: the tag goes to /git/refs" 0 "git/refs" \ + gh_after forge_tag_create 1.2.3 cafebabe +check "github: PRs behind a commit use the PLURAL path" 0 "commits/deadbeef/pulls" \ + gh_after forge_commit_pulls deadbeef + summary From ca99182e80c429404f4aabe0596f7c00c3c084b3 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 12:11:06 +0000 Subject: [PATCH 5/7] fix(forge): percent-encode asset names, and stop the docs naming a client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are @codex's on !193 (#1583), and both are real. The asset name travels as a QUERY VALUE, and the artifact-hook contract permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation meant `release asset.tgz` made curl reject the URL outright (exit 3), and '&', '#', '+', '%' silently changed the name or the query's shape. `gh release create` handled all of those, so a 1:1 port had to. Encoded through one boundary — jq's @uri, since jq is already a hard dependency of this backend and a hand-rolled sed class is how the next unescaped character gets through. Six backend cases cover it: the encoder on a space and on the delimiters, uploads under both names, the created release id in the path, and the multipart attachment. Mutation-checked: dropping the encoder fails exactly the two name assertions. docs/CONSUMERS.md's artifact-hook recovery still told operators to "run `gh release create` by hand" and described the hook as running "before `gh release create`" — on a Forgejo runner that is precisely the failure this PR fixes. It now names the forge-neutral tag-door recovery first and shows both clients for the manual path, without regressing the GitHub guidance. 1035 assertions, 22 suites, shellcheck-all and actionlint clean. Refs #191 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- changelog.d/191.md | 11 +++++++++++ docs/CONSUMERS.md | 30 +++++++++++++++++++++++++----- lib/forge-forgejo.sh | 17 +++++++++++++++-- test/forge-backends.test.sh | 28 +++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/changelog.d/191.md b/changelog.d/191.md index 89cf80f..4ca1fcd 100644 --- a/changelog.d/191.md +++ b/changelog.d/191.md @@ -26,3 +26,14 @@ - `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates it and every call reaches the network through there, so `repos//…` — whose 404 reads as "no release" and "no PRs" — is now impossible (#191). + +- Release asset names are percent-encoded. The hook contract permits any + filename, and the name travels as a query value: a space made curl reject + the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191). + +### Changed + +- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to + run `gh release create` by hand — on a Forgejo runner there is no `gh`. + It names the forge-neutral tag-door path first, with both clients shown + (#191). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 672f09f..fb244c2 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -247,8 +247,9 @@ tag door instead (the known first-release edge, cast#111). ### The artifact hook If the repository contains `.github/actions/release-artifact/action.yml`, -both doors invoke it — after the tag exists, before `gh release create` — -with the release `version` as input and `RELEASE_ASSETS_DIR` exported. +both doors invoke it — after the tag exists, before the release is +published — with the release `version` as input and `RELEASE_ASSETS_DIR` +exported. Contract for hook authors: - Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is @@ -259,9 +260,28 @@ Contract for hook authors: A failed hook leaves the tag created but no release published. Recovery is the tag door's semantics: fix the cause, then delete and re-push the same -tag (the tag door publishes for it), or run `gh release create` by hand from -a fixed tree. The merge door's nothing-exists assert will refuse a re-run of -the completed merge, by design. +tag — the tag door publishes for it. That path is forge-neutral and is the +one to prefer. + +If you must publish by hand instead, use whatever your forge provides; +ceremony itself no longer names a client here, because on a Forgejo runner +there is no `gh` to name (#191): + +```sh +# GitHub +gh release create "$VER" --verify-tag --title "$VER" \ + --notes-file notes.md -R "$OWNER/$REPO" + +# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases +curl -sS -X POST -H "Authorization: token $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg t "$VER" --rawfile b notes.md \ + '{tag_name:$t,name:$t,body:$b}')" \ + "$FORGE/api/v1/repos/$OWNER/$REPO/releases" +``` + +The merge door's nothing-exists assert will refuse a re-run of the +completed merge, by design. No hook → no assets: for a pure-bash tree, GitHub's source tarball for the tag IS the package. Worked examples land with the conversions: cast's tgz diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 940751c..426776a 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -612,6 +612,13 @@ forge_tag_create() { "$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null } +# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already +# a hard dependency of this backend, and @uri is its one correct answer; a +# hand-rolled sed class is how the next unescaped character gets through. +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. @@ -627,12 +634,18 @@ forge_release_create() { [ "$#" -gt 0 ] || return 0 base="$(forgejo_api_base)" || return 1 token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" - local f + local f name for f in "$@"; do [ -e "$f" ] || continue + # The asset name is a QUERY VALUE, and the hook contract permits any + # file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke + # on a space (curl exits 3 on the malformed URL) and silently changed + # the name on '&', '#', '+' and '%' — `gh release create` handled those, + # so a 1:1 port had to as well (#191, found by @codex on !193). + name="$(forgejo_urlencode "$(basename "$f")")" curl -sS -f -X POST -H "Authorization: token $token" \ -F "attachment=@$f" \ - "$base/repos/$REPO/releases/$id/assets?name=$(basename "$f")" >/dev/null \ + "$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \ || { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; } done } diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index 2793231..d59f39e 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -649,7 +649,8 @@ release_stub() { -o) out="$2"; shift ;; -X) method="$2"; shift ;; -d) payload="$2"; shift ;; - -H | -F) shift ;; + -F) payload="$payload -F $2"; shift ;; + -H) shift ;; -*) ;; *) url="$1" ;; esac @@ -715,6 +716,31 @@ release_stub 201 '{"id":42}' 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" +# Assets: the hook contract permits any filename the consumer drops in +# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw +# interpolation exits 3 on a space and silently renames on '&' / '#' / '+' / +# '%' — `gh release create` handled those, so the forgejo twin must too +# (#191, @codex on !193). +check "the encoder escapes a space" 0 "release%20asset.tgz" \ + forgejo_urlencode 'release asset.tgz' +check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \ + forgejo_urlencode 'a&b#c+d%e.tgz' + +printf 'x\n' >"$TMP/release asset.tgz" +printf 'y\n' >"$TMP/a&b.tgz" +release_stub 201 '{"id":42}' +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}' +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}' +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}' +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" + # --- the github twins address their own paths ---------------------------- . "$ROOT/lib/forge-github.sh" GITHUB_REPOSITORY=o/r From 4057c59354a49d638c161c268e1a385ecdbfe671 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 15:06:53 +0000 Subject: [PATCH 6/7] =?UTF-8?q?drill(0.4.1):=20the=20post-merge=20rehearsa?= =?UTF-8?q?l=20passed=20=E2=80=94=20record=20both=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #191's last acceptance criterion was a live drill against the MERGED tree, not the candidate. Run against `fda5657`: probe 1 merge door one release 0.4.1, changelog body, main re-armed to 0.4.2-dev, both assets uploaded probe 3 no label refused, nothing created probe 5 tag door 0.5.0 published, main VERSION untouched probe 6 bad tag refused, nothing created The fixture carried an artifact hook this time, dropping `drill asset.tgz` and `a&b.tgz`. Both survived under those exact names — the encoding fix proven end to end, in the place it would have failed: after the tag exists, mid-publish. The record keeps run 1 (the failure at 9a229ee) beside run 2, because the failure is why #191 exists and a record that quietly replaced it would be the kind of tidy history this repo refuses. Refs #191 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- drills/0.4.1.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 drills/0.4.1.md diff --git a/drills/0.4.1.md b/drills/0.4.1.md new file mode 100644 index 0000000..434d606 --- /dev/null +++ b/drills/0.4.1.md @@ -0,0 +1,63 @@ +# 0.4.1 — drill record + +Two runs. The first, 2026-08-04 against release PR !190 head `9a229ee`, +**failed**: both doors were inoperable and the release could not publish at +all. The second, after #191 landed as `fda5657`, **passed**. Both are +recorded, because the first is why the second exists. + +## Run 2 — against merged `main` `fda5657` (the one that counts) + +Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1-final`, +armed at `0.4.1-dev`, carrying the `docs/CONSUMERS.md` release caller, a +fragment-mode fixture, and — unlike run 1 — an **artifact hook** dropping two +deliberately awkward filenames, `drill asset.tgz` and `a&b.tgz`. Archived at +the end; the operator's delete is pending, and cleanup gates nothing. + +Candidate ref: `cluade-reviewer-andresmgsl/ceremony@drill-main`, parent +`fda5657`, whose only extra commit rewrites both `CEREMONY_SELF_REF` +carriers to that SHA — `release.yml`'s self-checkout is hardcoded to +`heavy-duty/ceremony`, so only a SHA that resolves there can stand in for a +tag that does not exist yet. No `0.4.1` branch was created on +`heavy-duty/ceremony`. + +| # | probe | result | +|---|---|---| +| 1 | merge-door ceremony | ✅ exactly one release `0.4.1`; body is the version's own changelog section; **main re-armed to `0.4.2-dev`**; both assets uploaded | +| 3 | bare version, no `release` label | ✅ refused — release count stayed **1** | +| 5 | tag door, matching tag | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** | +| 6 | mismatched tag | ✅ `9.9.9` refused — release count stayed **2** | + +Not run: probe 2 beyond the implicit `-dev` no-op at fixture push, and probe +4 — a re-run of a completed ceremony, which needs a re-trigger this round did +not exercise. Recorded as not-run rather than implied. + +**The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both +appear under those exact names on both releases. Before #191's fix the space +made curl reject the URL outright and the `&` split the query — the failure +landing *after* the tag exists, mid-publish, which is the worst place this +door has. + +## Run 1 — against `9a229ee`, before the fix (FAILED) + +Recorded in full because the failure is the reason #191 exists. + +| # | probe | result | +|---|---|---| +| 1 | merge-door ceremony | ❌ `decide` refused: `labeled=no` for a PR that *was* labeled and *was* merged | +| 5 | tag door | ❌ `gh: command not found` at *publish the release* | +| 6 | mismatched tag | ✅ refused, creating nothing | + +Root cause, both doors: `lib/facts.sh` and `release.yml` spoke `gh`, which +the runner image does not ship. `facts.sh` read *any* failure as a definite +`no`, so a missing binary silently demoted a release ceremony to "a bare +push". Release count across the whole of run 1: **0**. + +## What changed between them + +#191, merged as `fda5657`: both doors onto `lib/forge.sh`; a read that did +not complete refuses instead of fabricating a `no`; `forgejo_api_base` +refuses an empty `REPO` so `repos//…` cannot be addressed; release asset +names percent-encoded. + +Every refusal path, in both runs, created nothing. That property never +broke — what broke was the doors' ability to say yes. From d089ab57b32a3842adc0bd8d232a7fe7e13228ec Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders> Date: Tue, 4 Aug 2026 15:30:55 +0000 Subject: [PATCH 7/7] =?UTF-8?q?drill(0.4.1):=20probes=202=20and=204=20ran?= =?UTF-8?q?=20=E2=80=94=20all=20six=20probes=20now=20have=20live=20results?= =?UTF-8?q?=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex (!194 #1588) is right on the text: #191's criterion is a drill that runs all six probes with 1 and 5 passing, not two probes passing and four recorded. Probe 2 (mislabeled ordinary PR) and probe 4 (a re-run of the completed ceremony) were run on the same consumer, un-archived for them and archived again after. Probe 4 diverges in mechanism because Forgejo 8.0.3 has no run-rerun API: the ceremony was re-run by reproducing its input rather than replaying the run. The record says so, and says which assert refused. --- drills/0.4.1.md | 61 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/drills/0.4.1.md b/drills/0.4.1.md index 434d606..0f79350 100644 --- a/drills/0.4.1.md +++ b/drills/0.4.1.md @@ -20,16 +20,59 @@ carriers to that SHA — `release.yml`'s self-checkout is hardcoded to tag that does not exist yet. No `0.4.1` branch was created on `heavy-duty/ceremony`. -| # | probe | result | -|---|---|---| -| 1 | merge-door ceremony | ✅ exactly one release `0.4.1`; body is the version's own changelog section; **main re-armed to `0.4.2-dev`**; both assets uploaded | -| 3 | bare version, no `release` label | ✅ refused — release count stayed **1** | -| 5 | tag door, matching tag | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** | -| 6 | mismatched tag | ✅ `9.9.9` refused — release count stayed **2** | +| # | probe | run | result | +|---|---|---|---| +| 1 | merge-door ceremony | 637 | ✅ exactly one release `0.4.1`; body is the version's own changelog section; tag and release on merge commit `4a83fa1b`; **main re-armed to `0.4.2-dev`**; both assets uploaded | +| 2 | mislabeled ordinary PR | 652 | ✅ green no-op in **7s** on merge commit `e71df4e3` — release count stayed **2**, no tag created | +| 3 | bare version, no `release` label | 638 | ✅ refused at `b091aff2` — release count stayed **1** | +| 4 | re-run of the completed ceremony | 654 | ✅ refused in **8s** on merge commit `82e7d11b` — release count stayed **2**, and tag `0.4.1` **stayed on `4a83fa1b`**, the original merge commit | +| 5 | tag door, matching tag | 639 | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** | +| 6 | mismatched tag | 640 | ✅ `9.9.9` refused — release count stayed **2**; the tag exists, the release does not | -Not run: probe 2 beyond the implicit `-dev` no-op at fixture push, and probe -4 — a re-run of a completed ceremony, which needs a re-trigger this round did -not exercise. Recorded as not-run rather than implied. +Probes 2 and 4 were run last, at 15:23–15:28Z, on the same consumer: it was +un-archived for them and archived again at the end. Nothing else about the +run changed — same candidate ref, same caller pin. + +**Probe 4 diverges in mechanism, not in what it proves.** The 0.3.0 and 0.4.0 +siblings re-ran the completed ceremony's workflow run (GitHub's "attempt 2"). +Forgejo 8.0.3 exposes no run-rerun API — there are no `actions/runs/{id}` +routes in its swagger at all — so the ceremony was re-run by reproducing its +input instead: main re-armed to `0.4.1-dev` (setup run 653), then a second +`release`-labeled PR stamping bare `0.4.1` merged on top. That is the same +state the door refuses on, reached by a push rather than a re-trigger, and it +is stricter than the sibling shape in one way — it re-enters through +`facts` → `decide` rather than replaying a decided run. + +Which refusal fired is measurable even without run logs. Replaying the +door's own inputs against the live consumer at `82e7d11b`: + +``` +facts: ver='0.4.1' base_ver='0.4.1-dev' released='' labeled='yes' +decide: ceremony=yes +``` + +So `decide` said **go** — a labeled bare transition is row 6 — and the stop +came from the merge door's own pre-publish assert, `release.yml:216-219`, +whose comment names this exact probe ("what makes a re-run of a completed +ceremony refuse instead of clobber"): the tag existed, so it refused before +`forge_tag_create` ever ran. The second net behind it is #191's own verb, +and it reads this consumer correctly: + +``` +forge_release_exists 0.4.1 -> yes 0.5.0 -> yes 9.9.9 -> no +``` + +`9.9.9` is the probe-6 receipt in the same breath: the tag exists, the +release does not. + +Evidence for every probe here is the run conclusion plus the repository +state before and after — this instance serves no run logs (the API 404s on +every log route), so no probe's result is quoted from a log line. + +End state, as observed when this was written: releases `0.4.1` and `0.5.0` +and nothing else; tags `0.4.1` on `4a83fa1b`, `0.5.0` and `9.9.9` on +`b091aff2`; the consumer's `main` left at bare `0.4.1` where probe 4 stopped +it, private and archived. **The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both appear under those exact names on both releases. Before #191's fix the space