probe: exercise forge_detect/forge_preflight on a real Forgejo runner
All checks were successful
probe / preflight (push) Successful in 6s

This commit is contained in:
probe 2026-08-02 18:30:26 +00:00
parent 841fced6d9
commit 2dc517534e
2 changed files with 203 additions and 8 deletions

View file

@ -1,14 +1,24 @@
name: probe
on: [push]
jobs:
env:
preflight:
runs-on: ubuntu-latest
steps:
- name: dump the forge-identifying environment
- uses: actions/checkout@v4
- name: forge_detect must say forgejo, from the runner's own env
run: |
echo "--- GITHUB_* ---"
env | grep -E '^GITHUB_' | sort
echo "--- GITEA_/FORGEJO_/ACTIONS_ ---"
env | grep -E '^(GITEA_|FORGEJO_|ACTIONS_|CI=|RUNNER_)' | sort
echo "--- clients present ---"
for b in gh curl jq node stoke; do printf '%s: %s\n' "$b" "$(command -v $b || echo ABSENT)"; done
. lib/forge.sh
got="$(forge_detect)" || { echo "DETECT FAILED"; exit 1; }
echo "forge_detect -> $got"
[ "$got" = forgejo ] || { echo "WRONG: wanted forgejo"; exit 1; }
- name: forge_preflight must REFUSE the gh client here
run: |
. lib/forge.sh
if CEREMONY_FORGE_CLIENT=gh forge_preflight; then
echo "REGRESSION: preflight passed a gh client on a Forgejo forge"; exit 1
fi
echo "refused as required (exit non-zero above)"
- name: forge_preflight must PASS the rest client here
run: |
. lib/forge.sh
CEREMONY_FORGE_CLIENT=rest forge_preflight && echo "rest client accepted"

185
lib/forge.sh Normal file
View file

@ -0,0 +1,185 @@
#!/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
}