#!/usr/bin/env bash # Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh # (issue #188, term 1). set -u, not -e. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" # shellcheck source=lib/forge.sh . "$ROOT/lib/forge.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT eq() { local want="$1" got shift got="$("$@")" || return 1 [ "$got" = "$want" ] } # --- forge_select: exactly one backend, chosen deliberately ------------- check "select github loads the github backend" 0 "" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null' check "select forgejo loads the forgejo backend" 0 "" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null' check "select refuses an unknown forge" 1 "unknown forge" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab' # shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process check "select with no argument reads the environment" 0 "" \ bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]' # --- the page-size contract, both dialects ------------------------------ # The trap, measured 2026-08-02: each forge silently ignores the OTHER's # page-size parameter and answers HTTP 200 with fewer items. # # ?per_page=100 GitHub 100 Forgejo 30 (ignored) # ?limit=100 GitHub 30 Forgejo 50 (capped) # # So no call site names one, and these two functions are the only places # that decide. Pure on purpose: the contract is testable without a network. . "$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 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open' check "forgejo: a bare path gets a query" 0 "" \ eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1 check "forgejo: an existing query is preserved" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2 # A caller that names a page size anyway must not be able to reintroduce the # truncation — the parameter is stripped in BOTH dialects, on both backends, # because the whole point is that the boundary decides and the call site # cannot override it by accident. check "github strips a stray per_page" 0 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30' check "github strips a stray limit" 0 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100' check "forgejo strips a stray per_page" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1 check "forgejo strips a stray limit" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1 check "stripping the only parameter leaves a clean query" 0 "" \ eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1 # --- the forgejo gather: complete, or loudly refused -------------------- # curl is stubbed as a function so these are hermetic. Each case writes the # headers and body a real Forgejo would. # fake_forge — install a curl stub serving as # successive page bodies, declaring in x-total-count. An empty # string omits the header entirely (@kimi's #4699 case). A comma-separated # spec declares a DIFFERENT total per page ("4,9"), which is # @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712): # a server whose count moves under the walk cannot have been read whole. fake_forge() { FAKE_TOTAL="$1"; shift FAKE_PAGES=("$@") FAKE_CALLS=0 # shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api curl() { local hdr="" out="" url="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; -*) ;; *) url="$1" ;; esac shift done local page=1 case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac local total="$FAKE_TOTAL" case "$FAKE_TOTAL" in *,*) total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")" [ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)" ;; esac { printf 'HTTP/1.1 200 OK\r\n' [ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total" printf '\r\n' } >"$hdr" if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out" else printf '[]' >"$out" fi FAKE_CALLS=$((FAKE_CALLS + 1)) return 0 } } export CEREMONY_FORGE_API=https://forge.example/api/v1 # One page, and the count agrees with the declared total. fake_forge 2 '[{"number":1},{"number":2}]' check "a complete single-page gather returns its items" 0 "" \ eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # Two pages that add up. The walk must not stop at the first page merely # because it came back non-empty — rig has 137 issues across 3 pages, which # is the case this models. fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]' check "a multi-page gather walks every page" 0 "" \ eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # The whole reason the assert exists: a server that declares more than it # hands over must not produce a "successful" partial sweep. fake_forge 137 '[{"number":1},{"number":2}]' check "a short gather is refused, not reconciled" 1 "incomplete gather" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and the refusal names both counts" 1 "collected 2 of 137" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able # to degrade silently either. A Forgejo that does not expose x-total-count # leaves the assert with nothing to compare, and an assert that cannot run # must refuse rather than pass. fake_forge '' '[{"number":1},{"number":2}]' check "a missing x-total-count refuses" 1 "did not send x-total-count" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which # an unprovable read could still have been reported as a whole one — the # guard leaking the failure class it was built to stop, which is why they # are refusals rather than warnings. # A total that is not a number went straight into arithmetic. Reproduced on # ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as # the total. fake_forge 'not-a-number' '[{"number":1}]' check "a non-numeric total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and the refusal quotes what arrived" 1 "not-a-number" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge '12x' '[{"number":1}]' check "a partly-numeric total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge '-3' '[{"number":1}]' check "a negative total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A total that MOVES under the walk. The loop read it once, so a board # changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and # the walk stopped at 4 believing itself complete. fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]' check "a total that changes between pages is refused" 1 "changed between pages" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # The distinguishing text, not a substring that survives losing half the # message: "4" alone stayed green if the later total vanished, which is what # @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734) # both caught. A test named "names BOTH totals" must fail when one goes. check "...and the refusal names both totals" 1 "4 then 9" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A 200 whose body is not a collection. `length` on a non-array counted 0, # so an object or a scalar arriving where a list belongs read as a complete # EMPTY collection when the declared total was 0 — silence dressed as a # clean sweep. fake_forge 0 '{"message":"Not found"}' check "a non-array body is refused" 1 "did not return a collection" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge 0 '"a string"' check "a scalar body is refused" 1 "did not return a collection" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A genuinely empty collection is still fine — the refusal must not fire on # a repo that legitimately has nothing. fake_forge 0 '[]' check "an empty collection is not an error" 0 "" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # --- HTTP failures are named, not swallowed ----------------------------- # gh exits non-zero on an HTTP error; curl does not without -f, and -f # discards the body that explains why. So the status is read explicitly. fake_forge 1 '[{"number":1}]' # shellcheck disable=SC2317 # invoked indirectly, by forge_api curl() { local hdr="" out="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac shift done printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr" printf '{"message":"Not found"}' >"$out" return 0 } check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999' check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999' # --- forge_issue_edit: a typo must not become a green no-op -------------- # @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is # given to `gh`, which fails on a flag it does not know. Dropping it here # instead turned a mis-typed port site into a mutation that silently did not # happen — this issue's own failure class, arriving inside the fix for it. check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label # --- forge_label_create: an upsert, like gh's --force -------------------- # bootstrap_labels creates every declared label on EVERY workflow_dispatch, # so a plain POST onto an existing name aborts the bootstrap under set -e # from the second dispatch onward (#4743). WRITES="$TMP/writes" stub_writes() { : >"$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) shift ;; -*) ;; *) url="$1" ;; esac shift done # FAKE_FAIL_URL + FAKE_HTTP fault-inject one endpoint, so the refusal # boundaries are driven rather than assumed (#192 review). if [ -n "${FAKE_FAIL_URL:-}" ] && [ "${url##*"$FAKE_FAIL_URL"}" != "$url" ]; then printf 'HTTP/1.1 %s Server Error\r\n\r\n' "${FAKE_HTTP:-500}" >"$hdr" printf '{}' >"$out" [ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" return 0 fi printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr" case "$url" in *"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;; # The issue itself: the removal path reads its CURRENT label set before # computing the set to PUT (#192). */issues/[0-9]*) printf '{"labels": %s}' "${FAKE_ISSUE_LABELS:-[]}" >"$out" ;; *) printf '{}' >"$out" ;; esac [ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" return 0 } } # The label does not exist yet -> POST (create). FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue' check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES" # The label already exists -> PATCH (update), which is what --force does. FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text' check "recreating an existing label PATCHes it" 0 "" \ grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES" check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES" check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES" # --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------ # Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2, # #4751 item 2). Live scratch-repo evidence proved these work; these prove # they keep working, and pin the SHAPE of the requests. # Removal is a FULL-SET PUT, not a per-label DELETE (#192). Measured under a # real Actions token, probe run 701: DELETE .../labels/{id} -> 500 for every # removal, PUT .../labels -> 200 including the empty set. A PAT gets 204 on the # same DELETE, which is why it went unseen — it fails only for the identity the # sweep holds. ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13},{"name":"attention","id":14}]' FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old check "removing a label PUTs the whole wanted set" 0 "" \ grep -q '^PUT repos/o/r/issues/5/labels ' "$WRITES" check "...and never DELETEs, which this instance answers 500" 1 "" \ grep -q '^DELETE ' "$WRITES" check "...carrying the surviving label's id and not the removed one" 0 '{"labels":[13]}' \ cat "$WRITES" # The contract @codex-reviewer-andresmgsl asked for (#5183): a full-set PUT # replaces everything, so removal alone proves nothing about PRESERVATION. One # call, a combined delta, and two bystanders that must survive it. FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \ FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13},{"name":"attention","id":14}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label state:new check "a combined add+remove is ONE write" 0 "" test "$(wc -l <"$WRITES")" -eq 1 preserves_bystanders() { # the PUT keeps state:new(12), scope:labels(13), attention(14) grep -q 12 "$WRITES" && grep -q 13 "$WRITES" && grep -q 14 "$WRITES" } check "...and preserves every unrelated label" 0 "" preserves_bystanders check "...while dropping only what was asked for" 1 "" grep -qE '(^|[^0-9])11([^0-9]|$)' "$WRITES" # A label the issue does not carry is a successful no-op that writes NOTHING, # matching gh: the reconcilers call --remove-label unconditionally to converge # state, so most calls here ask to remove something absent. Writing the # unchanged set back would open ceremony#128's read-modify-write window for no # state change at all, and the GET above is already the proof the sweep reached # the forge (@codex-reviewer-andresmgsl, #192 review). FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old check "removing an absent label succeeds" 0 "" test "$?" -eq 0 check "...writing nothing at all" 0 "" test ! -s "$WRITES" # A full clear is the empty set, which this instance answers 200 (run 701). FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old check "clearing the last label PUTs the empty set" 0 '{"labels":[]}' cat "$WRITES" # An add-label the repo does not have must refuse BEFORE any write: a PUT that # silently dropped an unresolvable name would remove a label nobody asked to # remove — a destructive write dressed as a partial success. FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes edit_unknown_add() { FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label no-such-label } check "an unknown add-label refuses" 1 "no label id" edit_unknown_add check "...before writing anything" 0 "" test ! -s "$WRITES" # The preserved-id contract, and the reason it is not merely an optimisation # (@codex-reviewer-andresmgsl, #192 review): a bystander's id comes from the # ISSUE payload, so preservation must not depend on a repository-wide list # that has nothing to do with this issue. Here `attention` is on the issue with # id 14 and is ABSENT from the repo-list fixture entirely — a resolution that # went through forgejo_label_ids would refuse or drop it. PARTIAL_ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13}]' FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 stub_writes FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 \ FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"attention","id":14}]' \ REPO=o/r forge_issue_edit 5 --remove-label state:old check "a bystander absent from the repo list is still preserved by its issue id" 0 \ '{"labels":[14]}' cat "$WRITES" # The two fault boundaries the acceptance plan names. Both must be non-zero # with the backend's own diagnostic, and neither may report success. fail_get() { FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5' \ REPO=o/r forge_issue_edit 5 --remove-label state:old } check "a failed current-label GET refuses, non-zero" 1 "" fail_get check "...naming the verb, the path AND the status, in one diagnostic" 1 \ "HTTP 500 from 'GET repos/o/r/issues/5'" fail_get get_write_count() { : >"$WRITES"; fail_get >/dev/null 2>&1; wc -l <"$WRITES"; } check "...having written nothing: the read failed before any mutation" 0 "0" \ get_write_count fail_put() { FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \ FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \ FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5/labels' \ REPO=o/r forge_issue_edit 5 --remove-label state:old } check "a failed replacement PUT refuses, non-zero" 1 "" fail_put check "...naming the verb, the path AND the status, in one diagnostic" 1 \ "HTTP 500 from 'PUT repos/o/r/issues/5/labels'" fail_put put_write_count() { : >"$WRITES"; fail_put >/dev/null 2>&1; wc -l <"$WRITES"; } check "...having attempted only the one PUT" 0 "1" put_write_count # An ADD-ONLY call keeps the additive POST (ceremony#128): a read-modify-write # there clobbered a label set two seconds after a builder wrote it. FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \ REPO=o/r forge_issue_edit 5 --add-label state:new check "an add-only edit still POSTs additively" 0 "" \ grep -q '^POST repos/o/r/issues/5/labels ' "$WRITES" check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES" # Adding takes names directly — no lookup, one request. FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale" check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES" check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES" # Assignees are SET, not added/removed: PATCH takes the whole list. So a # removal is a read-modify-write, and a naive translation would have cleared # every OTHER assignee as a side effect of removing one. assignee_stub() { : >"$WRITES" # shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit 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) shift ;; -*) ;; *) url="$1" ;; esac shift done printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr" printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out" [ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" return 0 } } assignee_stub REPO=o/r forge_issue_edit 5 --remove-assignee alice check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES" check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES" assignee_stub REPO=o/r forge_issue_edit 5 --add-assignee carol check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES" # --- forge_labels_add / forge_request_reviewer, both backends ------------ # @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the # call-site port and had no boundary pins of their own. # ceremony#128 is the whole reason forge_labels_add exists as its own verb. # The labeler action computed (labels-at-job-start union derived) and PUT the # whole set, so a label applied while the job ran was silently removed — # ceremony#128 lost its `release` label, the merge door's declared-intent # read, two seconds after the builder set it. This write must therefore be an # ADDITIVE POST and must never read-modify-write. FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \ REPO=o/r forge_labels_add 7 scope:docs scope:cli check "labels_add POSTs to the issue labels collection" 0 "" \ grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES" check "...carrying every name in one request" 0 "" \ grep -q '"scope:docs","scope:cli"' "$WRITES" # The regression that would reopen ceremony#128: any PUT, or a GET-then-write. check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES" check "...exactly one write, so nothing is read-modify-written" 0 "" \ test "$(wc -l <"$WRITES")" -eq 1 FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7 check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES" # The reviewer payload shape. Measured against this instance: the endpoint # serves post and delete only, and takes {"reviewers":[...]}. FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt check "request_reviewer POSTs to requested_reviewers" 0 "" \ grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES" check "...with the reviewers array payload" 0 "" \ grep -q '{"reviewers":\["danmt"\]}' "$WRITES" # The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned # by the command it builds rather than by an HTTP shape. gh_calls="$TMP/ghcalls" : >"$gh_calls" # shellcheck disable=SC2317 # invoked indirectly, by the github verbs gh() { printf '%s\n' "$*" >>"$gh_calls"; } # A subshell so the github backend does not stay loaded over the forgejo # cases below; REPO is deliberately scoped to it for the same reason. ( forge_select github # shellcheck disable=SC2030 # scoping REPO to this subshell is the point REPO=o/r forge_labels_add 7 scope:docs scope:cli forge_request_reviewer 9 danmt ) check "github labels_add uses the additive api POST, not issue edit" 0 "" \ grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls" check "...and never routes through issue edit --add-label" 1 "" \ grep -q 'issue edit' "$gh_calls" check "github request_reviewer posts the reviewer" 0 "" \ grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls" # --- term-5 pins for the batch verbs (codex 1566) ------------------------- # The forgejo twins have hermetic coverage below; these pin that the github # twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites. : >"$gh_calls" # shellcheck disable=SC2317 # invoked indirectly, by the github verbs gh() { printf '%s\n' "$*" >>"$gh_calls" if [ "$1" = api ]; then shift local jqexpr="" endpoint="" while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; --paginate) ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done local body='[]' case "$endpoint" in *'/issues/'*'/timeline'*) body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]' ;; *'/issues/'*'/comments'*) body='[{"created_at":"2026-08-01T10:00:00Z"}]' ;; *'/pulls/'*'/comments'*) body='[{"created_at":"2026-08-01T10:30:00Z"}]' ;; *'/pulls/'*'/commits'*) body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]' ;; esac if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi return 0 fi return 0 } gh_tl="$( forge_select github # shellcheck disable=SC2030 # scoping REPO to this subshell is the point REPO=o/r forge_timeline 42 )" check "github forge_timeline paginates the issue timeline endpoint" 0 "" \ grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls" check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \ jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null # The NEGATIVE half of "pass-through". A positive pin still passes if the # github path GAINS forgejo behaviour, and term 5 is a statement about what # must NOT change — so the drift these two catch is the only kind a # 1:1-extraction path realistically suffers. # # The GitHub timeline already IS the shape ruling.sh selects on, so a # projection here would be a second, divergent normalizer maintained by # nobody. check "...and never reshapes it, as the forgejo backend must" 1 "" \ grep -q 'issues/42/timeline.*--jq' "$gh_calls" : >"$gh_calls" gh_act="$( forge_select github # shellcheck disable=SC2030 # scoping REPO to this subshell is the point REPO=o/r forge_pr_activity 9 | sort )" check "github forge_pr_activity hits issue comments" 0 "" \ grep -q 'repos/o/r/issues/9/comments' "$gh_calls" check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \ grep -q 'repos/o/r/pulls/9/comments' "$gh_calls" check "github forge_pr_activity hits commits" 0 "" \ grep -q 'repos/o/r/pulls/9/commits' "$gh_calls" check "github forge_pr_activity emits all three timestamp sources" 0 "" \ test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')" # The forgejo backend derives inline comments from reviews with # comments_count > 0, because the flat endpoint 404s there. That derivation # must never appear on this path: GitHub serves the flat endpoint, and a # github twin quietly adopting the workaround is the exact "both backends # drift together" failure term 5 forbids. check "...never deriving them from reviews, as forgejo must" 1 "" \ grep -q 'pulls/[0-9]*/reviews' "$gh_calls" unset -f gh . "$ROOT/lib/forge-forgejo.sh" # --- forge_pr_review_requests: the forge's live request set ------------- # A stale verdict is not itself evidence that anybody was re-requested. The # backend reads the forge's request representation directly, so the state # machine can distinguish the builder's ball from the panel's (#238). review_requests_stub() { fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS" } # crew!96 after the builder pushed: two approvals and one requested change # belong to the older head, but no live REQUEST_REVIEW row exists. FAKE_REVIEWS_N=3 FAKE_REVIEWS='[ {"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"}, {"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"}, {"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"} ]' review_requests_stub check "supersedes 'a stale approval still owes a verdict': one never re-requested is not requested" 0 "" \ eq "" forge_pr_review_requests 96 # rig!146 after merge: all three panelists submitted, so the reviews endpoint # carries no REQUEST_REVIEW row even though requested_reviewers stayed stale. FAKE_REVIEWS_N=3 FAKE_REVIEWS='[ {"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:05:01Z"}, {"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:14:45Z"}, {"user":{"login":"claude-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:03:39Z"} ]' review_requests_stub check "supersedes 'the never-cleared forgejo field collapses to who actually owes': a merged PR yields nobody" 0 "" \ eq "" forge_pr_review_requests 146 # crew!97's opening request rows. Deliberately unordered with one duplicate: # the public contract is sorted unique logins, independent of API row order. FAKE_REVIEWS_N=5 FAKE_REVIEWS='[ {"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}, {"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"}, {"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"}, {"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}, {"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"} ]' review_requests_stub check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \ eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97 FAKE_REVIEWS_N=2 FAKE_REVIEWS='[ {"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}, {"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"} ]' review_requests_stub check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \ eq "claude-bot" forge_pr_review_requests 97 FAKE_REVIEWS_N=1 FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]' review_requests_stub check "supersedes 'a Forgejo request row is not an answer and leaves the login outstanding': its login is requested" 0 "" \ eq "kimi-bot" forge_pr_review_requests 97 # GitHub already exposes the exact live set on the PR object. Stub only the # network boundary and assert the same backend-neutral output contract. . "$ROOT/lib/forge-github.sh" # shellcheck disable=SC2317 # invoked indirectly, by forge_api gh() { [ "$1" = api ] || return 1 shift local jqexpr="" endpoint="" while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done [ "$endpoint" = repos/o/r/pulls/55 ] || return 1 jq -r "$jqexpr" <<'JSON' {"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]} JSON } check "github returns requested_reviewers from the PR object unchanged" 0 "" \ eq $'claude-bot\nglm-bot' forge_pr_review_requests 55 unset -f gh . "$ROOT/lib/forge-forgejo.sh" # --- forge_pr_view: newest verdict per context must win ------------------ # checks_state groups repeated contexts and selects the newest by # [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left # the winner to incidental array order, so a stale re-run could outrank the # live one (#4743). Forgejo's combined status carries created_at/updated_at # — measured on this instance, where Actions DO land as commit statuses # (rig main: "ci / check (push)" success, with created_at). pr_view_stub() { # shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view curl() { local hdr="" out="" url="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac shift done printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr" case "$url" in */status) printf '%s' "$FAKE_STATUS" >"$out" ;; *) printf '%s' "$FAKE_PR" >"$out" ;; esac return 0 } } # The FAILURE is older but listed second — array order would pick it. FAKE_STATUS='{"state":"failure","statuses":[ {"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}, {"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}' FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}' pr_view_stub view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view maps createdAt" 0 "" \ grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json" check "pr_view maps completedAt" 0 "" \ grep -q '"completedAt":' <<<"$view_json" check "pr_view maps mergeable to the UI string" 0 "" \ grep -q '"mergeable": "MERGEABLE"' <<<"$view_json" # Forgejo folds conflict-checking, conflict-check errors, real conflicts, and # WIP into one false boolean. These fixtures pin the distinctions the API # object still lets the backend report honestly (#236). FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":false}' view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view reports draft mergeability as unknown" 0 "" \ grep -q '"mergeable": "UNKNOWN"' <<<"$view_json" FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"base","draft":false,"mergeable":false}' view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view reports a fast-forward false mergeability as unknown" 0 "" \ grep -q '"mergeable": "UNKNOWN"' <<<"$view_json" FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":false}' view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view preserves a distinguishable real conflict" 0 "" \ grep -q '"mergeable": "CONFLICTING"' <<<"$view_json" # Unreachable on Forgejo: Mergeable() includes !IsWorkInProgress(). This # ordering fixture exists only to pin draft ahead of mergeable true (#236). FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":true}' view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view reads draft before the mergeable boolean" 0 "" \ grep -q '"mergeable": "UNKNOWN"' <<<"$view_json" # The real proof: feed it to the production classifier and confirm the newer # SUCCESS wins over the older FAILURE regardless of array order. # shellcheck source=actions/labels-reconcile/labels-reconcile.sh . "$ROOT/actions/labels-reconcile/labels-reconcile.sh" classified="$(checks_state <<<"$view_json")" check "the newest verdict per context wins, not the array order" 0 "" \ test "$classified" = SUCCESS # --- the api base must be known ----------------------------------------- check "no api base refuses" 1 "cannot reach the forge" \ bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base' # --- forge_timeline: project Forgejo labels into the GitHub event shape - # Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled, # .user.login -> .actor.login. Mutation-verified: collapsing add/remove or # emitting .user instead of .actor each reds its own case (#4853). timeline_stub() { : >"$timeline_calls" # shellcheck disable=SC2317 # invoked indirectly, by forge_api curl() { local hdr="" out="" url="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac shift done printf '%s\n' "$url" >>"$timeline_calls" local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}" case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then page_body="${FAKE_TL_PAGES[$((page - 1))]}" else page_body='[]' fi page_total="$(jq 'length' <<<"$page_body")" fi { printf 'HTTP/1.1 200 OK\r\n' [ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total" printf '\r\n' } >"$hdr" case "$url" in *timeline*) printf '%s' "$page_body" >"$out" ;; *) printf '[]' >"$out" ;; esac return 0 } } timeline_page() { jq -nc --argjson first "$1" --argjson count "$2" ' [range($first; $first + $count) | { type: "label", body: "1", user: {login: "setter"}, label: {name: "needs-ruling"}, created_at: ("event-" + tostring) }] ' } timeline_calls="$TMP/timeline_calls" FAKE_TL_PAGES=() FAKE_TL_HEADERS=yes FAKE_TIMELINE='[ {"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"}, {"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"}, {"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"} ]' FAKE_TL_N=3 timeline_stub tl="$(REPO=o/r forge_timeline 188)" check "forge_timeline projects body=1 to labeled" 0 "" \ jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null check "forge_timeline projects body=\"\" to unlabeled" 0 "" \ jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null check "forge_timeline drops non-label events" 0 "" \ test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0 check "forge_timeline uses .actor.login, not a bare .user" 0 "" \ jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null # Forgejo's timeline endpoint lies consistently: x-total-count echoes the # current page size. With 151 events its pages declare 50, 50, 50 and 1, so # strict pagination stops successfully after page 1 and drops the newest 101 # events. Exhaustion is safe only here because timelines are append-only. FAKE_TL_PAGES=( "$(timeline_page 1 50)" "$(timeline_page 51 50)" "$(timeline_page 101 50)" "$(timeline_page 151 1)" ) timeline_stub tl="$(REPO=o/r forge_timeline 188)" check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \ test "$(jq 'length' <<<"$tl")" = 151 check "forge_timeline retains the newest event beyond page one" 0 "" \ jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null # A collection exactly divisible by the page size needs one final empty read; # stopping after the second full page cannot prove exhaustion. FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)") timeline_stub exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')" check "exhaustive pagination terminates after an empty page" 0 "" \ test "$exhaustive_count" = 100 check "an exactly-full exhaustive gather reads the empty third page" 0 "" \ test "$(wc -l <"$timeline_calls")" = 3 # The exhaustive path's completeness proof is the short page itself; it must # never consult the endpoint's missing or dishonest total header. FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)") FAKE_TL_HEADERS=no timeline_stub check "exhaustive pagination needs no x-total-count header" 0 "" \ eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length' FAKE_TL_HEADERS=yes check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \ forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline' check "exhaustive pagination refuses a non-GET method" 1 "GET" \ forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline' check "the exhaustive flag has exactly one production call site" 0 "" \ test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5 # Unreadable: curl fails. Status must surface through forge_timeline itself # (not a later jq), or the ruling ladder invents a verdict on a half-read. # shellcheck disable=SC2317 curl() { return 22; } tl_unreadable() { REPO=o/r forge_timeline 188; } check "forge_timeline fails when the gather fails" 1 "" tl_unreadable # --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo ----------- # Only reviews with comments_count > 0 are fetched (#4844). activity_calls="$TMP/activity_calls" : >"$activity_calls" activity_stub() { # shellcheck disable=SC2317 curl() { local hdr="" out="" url="" total=1 body='[]' while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac shift done printf '%s\n' "$url" >>"$activity_calls" case "$url" in *'/issues/'*'/comments'*) total=1 body='[{"created_at":"2026-08-01T10:00:00Z"}]' ;; *'/pulls/'*'/commits'*) total=1 body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]' ;; *'/reviews/'*'/comments'*) total=1 body='[{"created_at":"2026-08-01T12:00:00Z"}]' ;; *'/pulls/'*'/reviews'*) total=2 body="$FAKE_REVIEWS" ;; *) total=0; body='[]' ;; esac printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr" printf '%s' "$body" >"$out" return 0 } } FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]' activity_stub : >"$activity_calls" act="$(REPO=o/r forge_pr_activity 9 | sort)" check "forge_pr_activity emits issue-comment timestamps" 0 "" \ grep -qx '2026-08-01T10:00:00Z' <<<"$act" check "forge_pr_activity emits commit timestamps" 0 "" \ grep -qx '2026-08-01T11:00:00Z' <<<"$act" check "forge_pr_activity emits inline review-comment timestamps" 0 "" \ grep -qx '2026-08-01T12:00:00Z' <<<"$act" check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \ grep -q '/reviews/7/comments' "$activity_calls" check "...and never fetches a zero-comment review" 1 "" \ grep -q '/reviews/8/comments' "$activity_calls" 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 — 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 ;; -F) payload="$payload -F $2"; shift ;; -H) shift ;; -*) ;; *) url="$1" ;; esac shift done [ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr" [ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out" 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; } # forge_commit_at — the FOURTH asymmetry (#209). Forgejo 404s on /commits/{sha} # and serves the object at /git/commits/{sha}, with the committer date under # `.created` rather than `.commit.committer.date`. A stubbed forge_api cannot # catch a wrong PATH, which is how #198 shipped GitHub's path here and every # sweep printed `could not read the head commit's date`. release_stub 200 '{"created":"2026-08-05T13:11:33Z","commit":{"committer":{"date":"WRONG"}}}' check "forgejo: the commit date comes from .created" 0 "2026-08-05T13:11:33Z" \ forge_commit_at deadbeef fj_not_nested() { ! forge_commit_at deadbeef | grep -q WRONG; } check "...and never from GitHub's nested field" 0 "" fj_not_nested check "forgejo: it asks /git/commits/{sha}" 0 "git/commits/deadbeef" \ writes_after forge_commit_at deadbeef fj_not_bare_path() { ! grep -qE 'repos/o/r/commits/deadbeef( |$)' "$WRITES"; } check "...and never the bare /commits/{sha}, which 404s here" 0 "" fj_not_bare_path 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" # 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 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 # The other half of #209's asymmetry: GitHub serves a single commit at the bare # path, with the date nested. Swapping the two backends' paths must red one of # these two files, which is the whole point of pinning both. check "github: a single commit is the BARE path" 0 "commits/deadbeef" \ gh_after forge_commit_at deadbeef gh_not_git_commits() { ! gh_after forge_commit_at deadbeef | grep -q 'git/commits'; } check "...and never Forgejo's git/commits" 0 "" gh_not_git_commits check "...reading the nested committer date" 0 "commit.committer.date" \ gh_after forge_commit_at deadbeef summary