#!/usr/bin/env bash # lib/forge.sh — one forge abstraction, two backends (issue #188). # # Sourced, never executed: no set -e/-u here — the sourcing script owns its # own shell options, exactly as lib/version.sh does. This file is the # selector only; the backends live beside it in lib/forge-github.sh and # lib/forge-forgejo.sh, and nothing here talks to a network. # # WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh` # all the way down — 61 runtime call sites, no indirection, no forge check. # Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs # its CI on a Forgejo Actions runner) they did not fail usefully. Measured # against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4: # # labels-scope exit 0 "no .github/labeler.yml at main — nothing # to derive" — the file exists (HTTP 200) # labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs # issueflow-reconcile exit 1 "unexpected end of JSON input" # # Two of the three reported SUCCESS having read nothing. labels-reconcile's # own blind-sweep warning (#96) could not fire, because it counts unreadable # PRs against a list `gh pr list` never produced — and a process # substitution's failure does not trip set -e, so `total` stayed 0 and the # sweep called itself reconciled. rig run 979 is the log. # # The tempting fix — install gh on the runner — makes it WORSE. gh speaks # GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no # GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the # one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405, # prints nothing, exits into the same empty loop) and all three actions go # green while reading nothing. That is this repo's own doctrine — an # unreadable rollup reads as "nothing is failing" — being violated by the # repo that wrote it. # # So: the forge is decided ONCE, before any sweep, and a client that cannot # speak it refuses loudly. Never "probably github". # forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot # tell. Order matters and every signal below was measured, not read from # docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders # (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted # runner's is the control. # # The trap that makes this non-obvious: **the Forgejo runner populates the # whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY, # GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on # "GITHUB_ACTIONS is set" would answer "github" on both forges, which is # precisely the bug. What actually differs: # # signal GitHub Forgejo (measured) # GITHUB_API_URL https://api.github.com https:///api/v1 # GITHUB_GRAPHQL_URL https://api.github.com/… (empty) # GITEA_ACTIONS (unset) true # # GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the # forge telling us the two `gh api graphql` sites #188 retired can never # work here. It is deliberately NOT a detection signal, though: an empty # variable is also what a hand-rolled harness leaves behind, and a signal # that fires on absence is a signal that fires by accident. forge_detect() { # 1. The explicit override outranks every probe — the escape hatch for a # forge this file has not met, and the handle the tests drive. A typo # in it is fatal on purpose: the operator said something and it was # wrong, and falling through to a probe that guesses right by accident # would hide that until the guess was wrong too. if [ -n "${CEREMONY_FORGE:-}" ]; then case "$CEREMONY_FORGE" in github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;; *) echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2 return 1 ;; esac fi # 2. Forgejo's and Gitea's own positive marker. Unambiguous where a # hand-set GITHUB_API_URL might not be, so it is read first. if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then printf 'forgejo\n' return 0 fi # 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub # Enterprise Server alike — GHES is a github backend on a non-github.com # host, and routing it to the forgejo backend would regress term 5's # "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo # serves. case "${GITHUB_API_URL:-}" in https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;; */api/v3 | */api/v3/*) printf 'github\n'; return 0 ;; */api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;; esac # 4. Last resort, the server host. Only github.com itself is conclusive # here: a bare hostname says nothing about which API it serves. case "${GITHUB_SERVER_URL:-}" in https://github.com | https://github.com/*) printf 'github\n'; return 0 ;; esac # 5. Refuse. "Nothing to read" is not "probably github" — guessing here # reinstates the exact blind sweep this file exists to end. Name what # was inspected and the escape hatch, so the log answers "why" without # a second run (#101 D5, one layer up: report, do not diagnose). cat >&2 <&2 return 1 ;; esac # shellcheck source=/dev/null . "$FORGE_LIB_DIR/forge-$forge.sh" || return 1 # Read by callers and tests to assert which backend is loaded, so the # choice is inspectable rather than implied by which functions exist. # shellcheck disable=SC2034 # consumed by sourcing scripts, not this file FORGE="$forge" } # forge_client — print the client that backend requires. # # github -> gh the current call set, extracted 1:1 (term 5) # forgejo -> rest /api/v1 over curl+jq # # forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo # instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, # probe task 278) carries curl, jq and node — and has neither `gh` NOR # `stoke` on PATH. That second absence is what retired option A from the # ruling: porting the call sites to the stoke CLI would have put a binary # on the critical path that the runner does not have and that would need # installing before every job. forge_client() { case "${1:?forge_client: forge required}" in github) printf 'gh\n' ;; forgejo) printf 'rest\n' ;; *) echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2 return 1 ;; esac } # forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge # and proves the client can speak it, or exits non-zero with a named reason. # # CEREMONY_FORGE_CLIENT declares the client the caller will actually use — # how a call site that still hard-codes `gh` announces itself honestly while # the backends are being ported. Two checks run, in order: # # 1. the declaration, when made, must match what this forge needs; # 2. that client's binaries must actually be on PATH — checked whether or # not a declaration was made, because a call site that declares the # right client on a runner that lacks it is still a blind sweep waiting # to happen. forge_preflight() { local forge want forge="$(forge_detect)" || return 1 want="$(forge_client "$forge")" || return 1 if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then cat >&2 </dev/null 2>&1 || missing_bins+=(gh) ;; rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;; esac if [ "${#missing_bins[@]}" -gt 0 ]; then cat >&2 <