diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index e0b3174..d876ff4 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -27,6 +27,8 @@ TRIAGE_ACTORS=() # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# shellcheck source=lib/forge.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" log() { printf 'issueflow: %s\n' "$*"; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } @@ -484,6 +486,12 @@ reconcile_opened_issue() { } main() { + # See labels-reconcile's twin (#188). This one already failed loudly on + # Forgejo — but with `line 408: gh: command not found`, which names the + # symptom and not the cause, and only after the sibling step had already + # reported a green blind sweep. + CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + local owner name REPO="${REPO:?set REPO to owner/name}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 9e7d337..9936f7e 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -53,6 +53,8 @@ STALE_AFTER=$((48 * 3600)) # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# shellcheck source=lib/forge.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" log() { printf 'labels: %s\n' "$*"; } @@ -705,6 +707,12 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch } main() { + # BEFORE anything reads the board (#188). Every call site below is still + # `gh`, so that is what this declares — honestly, which is the point: on + # a Forgejo consumer the preflight refuses here instead of letting the + # sweep run blind and print "reconciled." over zero PRs (rig run 979). + CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + REPO="${REPO:?set REPO to owner/name}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" load_config "$LABELS_CONF" diff --git a/actions/labels-scope/labels-scope.sh b/actions/labels-scope/labels-scope.sh index b8a4544..f72f711 100644 --- a/actions/labels-scope/labels-scope.sh +++ b/actions/labels-scope/labels-scope.sh @@ -6,6 +6,9 @@ else set -u fi +# shellcheck source=lib/forge.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" + # labels-scope.sh — the additive half of the labels automation: derive # scope:* labels from a PR's changed paths and ADD them, touching nothing # else. This seat belonged to actions/labeler@v5 until #130: even under @@ -125,6 +128,12 @@ derive_labels() { # $1 = "labelglob" lines, $2 = changed files (one per } main() { + # See labels-reconcile's twin (#188). This action's degraded read was the + # quietest of the three: an unreadable mapping and an absent one produced + # the same "nothing to derive" no-op, so on Forgejo a PR simply got no + # scope labels and nothing said why. + CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + REPO="${REPO:?set REPO to owner/name}" PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}" CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}" diff --git a/changelog.d/188.md b/changelog.d/188.md new file mode 100644 index 0000000..4c7bc52 --- /dev/null +++ b/changelog.d/188.md @@ -0,0 +1,16 @@ +### Added + +- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from + the runner's own environment, `forge_client` names the client it needs, and + `forge_preflight` refuses loudly before any sweep when the two disagree + (#188). +- The reconcilers and `labels-scope` run that preflight first, so a + GitHub-shaped client on a Forgejo instance is a named refusal instead of a + sweep that reads nothing and reports success (#188). + +### Fixed + +- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo + consumer having read zero facts — measured on `heavy-duty/rig`, where the + sweep printed `reconciled.` over an empty PR list and scope reported "no + labeler.yml" for a file that exists (#188). 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 < — succeeds AND prints exactly . check()'s +# substring match cannot prove "forgejo" was not printed as "forgejox". +eq() { + local want="$1" got + shift + got="$("$@")" || return 1 + [ "$got" = "$want" ] +} + +# detect_in — run forge_detect in a clean environment +# carrying only the named vars, so a leaked GITHUB_* from the CI running +# THIS suite cannot decide the answer. Every case below is hermetic. +detect_in() { + env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect' +} + +preflight_in() { + env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight' +} + +# --- forge_detect: the explicit override -------------------------------- +# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge +# whose env this file has not met yet, and the handle the tests below use +# to drive the backends without a live instance. + +check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github +check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo +check "override refuses an unknown forge" 1 "unknown forge" \ + detect_in CEREMONY_FORGE=gitlab +# A typo must not silently fall through to a probe that guesses right by +# accident: the operator said something, and it was wrong. +check "override outranks the env" 1 "unknown forge" \ + detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com + +# --- forge_detect: GITHUB_API_URL, the load-bearing signal --------------- +# Measured on forgejo.heavyduty.builders 2026-08-02 with a real +# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner +# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so +# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where +# those URLs point: +# +# GitHub GITHUB_API_URL=https://api.github.com +# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1 +# +# That is the whole bug this issue exists for, in one variable: gh speaks +# /api/v3 against api.github.com, and neither half is true here. + +check "api url: api.github.com is github" 0 "" \ + eq github detect_in GITHUB_API_URL=https://api.github.com +check "api url: /api/v1 is forgejo" 0 "" \ + eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1 +# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and +# it is a github backend on a non-github.com host. Getting this wrong would +# route a GHES consumer to the forgejo backend and break term 5. +check "api url: GHES /api/v3 is github" 0 "" \ + eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3 + +# --- forge_detect: GITEA_ACTIONS, the positive marker -------------------- +# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278), +# which GitHub never sets. It is checked BEFORE the URL shape because it is +# unambiguous where a hand-set GITHUB_API_URL might not be. + +check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true +check "gitea marker outranks a github-shaped api url" 0 "" \ + eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com + +# --- forge_detect: refusing to guess ------------------------------------ +# Nothing to read is NOT "probably github". A wrong guess here is exactly +# the silent blind sweep #188 measured; the whole point of this file is +# that an unknown forge is loud. + +check "bare environment refuses" 1 "cannot determine which forge" detect_in +check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in +check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in + +# --- forge_preflight: the must-fail case -------------------------------- +# The Test plan's named must-fail: "point it at a Forgejo instance with a +# GitHub-shaped client and assert it refuses loudly rather than sweeping +# blind." +# +# Measured before this guard existed, against this instance: +# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists) +# labels-reconcile exit 0 "reconciled." (zero PRs read) +# issueflow-reconcile exit 1 "unexpected end of JSON input" +# Two of three swept blind and reported success. gh present made it WORSE: +# it silenced the one loud failure. Hence: refuse before the sweep, not +# after — and say which forge and which client, so the log answers "why" +# without a second run (#101 D5's report-do-not-diagnose, one layer up). + +check "forgejo + gh-only client refuses" 1 "cannot speak" \ + preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh +check "the refusal names the forge" 1 "forgejo" \ + preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh +check "the refusal names the client" 1 "gh" \ + preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh +# The refusal must be actionable, not merely loud: #188's whole cost was a +# red check that told nobody what to do. +check "the refusal names the issue" 1 "#188" \ + preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh + +# --- forge_preflight: the passing pairs --------------------------------- + +check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh +check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest +# The mirror of the must-fail: a Forgejo client against GitHub is just as +# wrong, and symmetric refusal is cheaper than explaining why only one +# direction is checked. +check "github + rest refuses" 1 "cannot speak" \ + preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest + +# --- forge_preflight: it refuses when the forge itself is unknown -------- +# Detection failure must not be swallowed into a pass — that would restore +# the blind sweep through the back door. +check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in + +# --- forge_client: what each backend actually needs ---------------------- +# Measured in the runner image the Forgejo instance actually uses +# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke +# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by +# necessity, not preference — this is the measurement that retired option +# A (port to stoke) as well: the CLI is not on the runner either. + +check "github backend wants gh" 0 "" eq gh forge_client github +check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo +check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab + +summary diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index a326da9..ef41045 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -581,9 +581,16 @@ printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql.json" arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } +# CEREMONY_FORGE=github below, and at the executable-sweep driver further +# down: these fixtures ARE a GitHub board (a gh stub on PATH answering +# /api/v3 shapes), so the suite says so at the forge boundary rather than +# letting main()'s preflight infer a forge from whatever env the CI job +# leaked (#188). Stubbing `gh` and staying silent about the forge is the +# boundary this issue moved. arrival_run() { : >"$ARRIVAL/fixtures/edits" env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" @@ -633,6 +640,7 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json" : >"$ARRIVAL/fixtures/edits" subprocess_out="$( env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 00fcafd..071f836 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -738,6 +738,9 @@ blind_main_probe() { GITHUB_EVENT_NAME=schedule REPO=owner/repo LABELS_CONF=.github/labels.conf + # This probe IS a GitHub board — say so at the forge boundary rather + # than leaving main()'s preflight to infer one (#188). + CEREMONY_FORGE=github gh() { if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1 @@ -922,6 +925,7 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf" exec_env() { # $1 = event name → the real script, executed under the PATH stub : >"$EXEC/record" env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \ + CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \ bash actions/labels-reconcile/labels-reconcile.sh }