diff --git a/.github/workflows/probe.yml b/.github/workflows/probe.yml index 61be6d3..6787b87 100644 --- a/.github/workflows/probe.yml +++ b/.github/workflows/probe.yml @@ -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" diff --git a/lib/forge.sh b/lib/forge.sh new file mode 100644 index 0000000..8100001 --- /dev/null +++ b/lib/forge.sh @@ -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:///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 < — 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+=("$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 <