Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: 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. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
185 lines
8.7 KiB
Bash
185 lines
8.7 KiB
Bash
#!/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://<host>/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 <<EOF
|
|
forge_detect: cannot determine which forge this is — refusing to guess (#188).
|
|
GITHUB_API_URL='${GITHUB_API_URL:-}'
|
|
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
|
|
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
|
|
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
|
|
EOF
|
|
return 1
|
|
}
|
|
|
|
# forge_client <forge> — 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 <<EOF
|
|
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
|
|
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
|
|
and has no GraphQL surface at all. Pointing one at the other does not
|
|
fail usefully — it reads nothing and reports success.
|
|
This forge needs the '$want' client.
|
|
EOF
|
|
return 1
|
|
fi
|
|
|
|
# Then prove the tools are actually here — declared or not. A missing
|
|
# binary is the rig failure verbatim, "line 692: gh: command not found",
|
|
# and it must be a refusal before the sweep, not a 127 halfway through
|
|
# one. Checked on BOTH paths deliberately: a call site that declares the
|
|
# right client on a runner that lacks it is still a blind sweep waiting
|
|
# to happen.
|
|
local missing_bins=() bin
|
|
case "$want" in
|
|
gh) for bin in gh; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
|
|
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 <<EOF
|
|
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
|
|
Refusing before the sweep: a reconciler that cannot read the board must
|
|
not report that it reconciled one.
|
|
EOF
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|