#!/usr/bin/env bash # lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq # (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says # forgejo; never sourced directly, and never at the same time as the github # backend — they define the same verbs on purpose. # # curl+jq rather than a CLI because that is what the runner has. The image # this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe # task 278) carries curl, jq and node, and has neither `gh` nor `stoke`. # forgejo_api_base — the /api/v1 root, from the runner's own environment. # GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured: # https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides # it for tests and for anyone driving this outside Actions. forgejo_api_base() { local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}" if [ -z "$base" ]; then echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2 return 1 fi printf '%s\n' "${base%/}" } # forgejo_page_url — pure, so the page-size contract is # testable without a network. Returns the endpoint with this backend's OWN # paging parameters applied. # # THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against # heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub: # # ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED) # ?limit=100 GitHub: 30 items Forgejo: 50 items (capped) # # Each forge silently ignores the other's page-size parameter, answers # HTTP 200 with valid JSON, and says nothing. Every call site in this repo # was written GitHub-shaped, so a verbatim port would have swept 30 of # rig's 137 and printed "reconciled." — acceptance criterion 2 failing # green, and the same "degraded read that does not report it degraded" # failure class this whole issue exists to kill. # # So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the # boundary once beats fixing nine call sites and trusting the tenth — the # same argument that chose shape C over B, one level down. # # 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on # this instance) whatever you ask for, so asking for more cannot help and # pagination is mandatory rather than an optimisation. forgejo_page_url() { local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}" # Strip any page-size parameter a caller left behind, in either dialect, # rather than trusting that none did: this function is the one place that # decides paging, and a stray per_page= would be exactly the silent # truncation above. local clean="$endpoint" clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')" case "$clean" in *\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;; *\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;; *) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;; esac } # forge_api [--paginate] [--jq ] # # --paginate walks page= until a short page, then PROVES the walk was # complete by comparing what it collected against the server's declared # x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING # header is a loud refusal, not a pass. Header exposure is a server setting # (access-control-expose-headers), and an instance that withholds it would # make the completeness check compare null to a number — the guard itself # degrading silently, which is the failure class re-entering through the # door built to stop it. forge_api() { local paginate=false endpoint="" jqexpr="" have_jq=false while [ $# -gt 0 ]; do case "$1" in --paginate) paginate=true ;; --jq) jqexpr="$2"; have_jq=true; shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done [ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; } local base token base="$(forgejo_api_base)" || return 1 token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" local hdr body hdr="$(mktemp)"; body="$(mktemp)" # shellcheck disable=SC2064 # the paths are fixed at trap time on purpose trap "rm -f '$hdr' '$body'" RETURN if [ "$paginate" = false ]; then if ! curl -sS -D "$hdr" -o "$body" \ -H "Authorization: token $token" -H 'Accept: application/json' \ "$base/$endpoint"; then echo "forge_api: request failed: $endpoint" >&2 return 1 fi forgejo_http_ok "$hdr" "$endpoint" || return 1 if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi return 0 fi # Paginated: accumulate into ONE array and apply --jq once at the end. # gh --paginate applies --jq per page and concatenates; for the `.[] | …` # shapes every call site here uses, the two are identical, and merging # first is what makes the completeness assert possible at all. local page=1 total="" got=0 n all="[]" pagejson while :; do if ! curl -sS -D "$hdr" -o "$body" \ -H "Authorization: token $token" -H 'Accept: application/json' \ "$base/$(forgejo_page_url "$endpoint" "$page")"; then echo "forge_api: request failed: $endpoint (page $page)" >&2 return 1 fi forgejo_http_ok "$hdr" "$endpoint" || return 1 if [ -z "$total" ]; then total="$(forgejo_total_count "$hdr")" || return 1 fi pagejson="$(cat "$body")" n="$(jq 'if type == "array" then length else 0 end' <<<"$pagejson")" [ "$n" -gt 0 ] || break all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")" got=$((got + n)) [ "$got" -lt "$total" ] || break page=$((page + 1)) done # The assert. A short read here is the silent-truncation bug arriving by # another route, so it is fatal rather than a warning. if [ "$got" -ne "$total" ]; then cat >&2 < — the declared size of the collection. # Absent is fatal (#4699): without it the completeness assert cannot run, # and an assert that cannot run must not silently pass. forgejo_total_count() { local hdr="$1" total total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)" if [ -z "$total" ]; then cat >&2 < — a non-2xx is named, not # swallowed. gh exits non-zero on HTTP failure; curl does not without -f, # and -f would throw away the body that says why. forgejo_http_ok() { local hdr="$1" endpoint="$2" code code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')" case "$code" in 2*) return 0 ;; *) echo "forge_api: HTTP $code from '$endpoint'" >&2 return 1 ;; esac }