178 lines
7.3 KiB
Bash
178 lines
7.3 KiB
Bash
|
|
#!/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 <endpoint> <page> — 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] <endpoint> [--jq <expr>]
|
||
|
|
#
|
||
|
|
# --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 <<EOF
|
||
|
|
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
||
|
|
Refusing rather than reconciling a partial board: a sweep over part of the
|
||
|
|
queue that reports success is the failure this shim exists to prevent.
|
||
|
|
EOF
|
||
|
|
return 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# forgejo_total_count <header-file> — 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 <<EOF
|
||
|
|
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
||
|
|
The header is exposed by a server setting (access-control-expose-headers).
|
||
|
|
Refusing: an unprovable read must not be reported as a whole one.
|
||
|
|
EOF
|
||
|
|
return 1
|
||
|
|
fi
|
||
|
|
printf '%s\n' "$total"
|
||
|
|
}
|
||
|
|
|
||
|
|
# forgejo_http_ok <header-file> <endpoint> — 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
|
||
|
|
}
|