#!/usr/bin/env bash # Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing # commands are behavior for the harness to inspect. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" # shellcheck source=lib/forge.sh . "$ROOT/lib/forge.sh" # eq — 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 # The interpolated client, not the bare string "gh" — which also appears in # the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion # stayed green even if the client name never reached the message. Same class # as the "names both totals" weakness the panel caught in the backend suite # (#4727 / #4734); found by auditing this file for the same shape. check "the refusal names the client" 1 "the 'gh' client cannot speak it" \ 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