probe: exercise forge_detect/forge_preflight on a real Forgejo runner
All checks were successful
probe / preflight (push) Successful in 6s
All checks were successful
probe / preflight (push) Successful in 6s
This commit is contained in:
parent
841fced6d9
commit
2dc517534e
2 changed files with 203 additions and 8 deletions
26
.github/workflows/probe.yml
vendored
26
.github/workflows/probe.yml
vendored
|
|
@ -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
185
lib/forge.sh
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue