All checks were successful
CI / test (pull_request) Successful in 3m9s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl reproduced it again, with one file: scanned_paths()
said "tracked" and used `find`, which walks the working directory and knows
nothing about the index.
Not pedantry — ci.yml extracts shellcheck.tar.xz, actionlint.tar.gz and their
binaries INTO the checkout before the suite runs, and any developer cache sits
there too. Today none happens to carry a matching marker; that is luck, not a
property, and a false red on a downloaded tarball would be indistinguishable
from a real finding.
`git ls-files -z` makes "tracked" executable rather than prose.
The fixtures become tiny git repositories, because a fixture that is only a
directory is invisible to ls-files and every must-fail below it would have
passed vacuously — the same trap as the earlier teeth that never invoked the
guard. Plus the negative case he asked for: an untracked marker-bearing cache
file is ignored, and the moment it is TRACKED the guard sees it.
Reverting discovery to find reds three.
Branch updated from merged main (e236318, now carrying !206) before verifying:
upstream-delta 28/28, test/run.sh 29/29, shellcheck 0.10.0 clean.
Refs #200
306 lines
14 KiB
Bash
306 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
# The delta-inventory guard (#200, enforcing #197 D3's standing constraint).
|
|
#
|
|
# Forge-specific behaviour lives in the files docs/UPSTREAM-SYNC.md names,
|
|
# and nowhere else.
|
|
#
|
|
# WHY. This tree carries upstream's content plus a forge delta, forever — the
|
|
# sync is recurring and upstream is read-only (#197 D3). What makes that
|
|
# tractable is not luck: the 0.6.0 sync cost 18 conflict hunks across 10 files
|
|
# because the delta sits in six files upstream never touches. Scatter
|
|
# `forge_detect` into a seventh and every future sync pays for it, in a place
|
|
# nobody chose.
|
|
#
|
|
# So this fails on the PR that scatters it, rather than on the sync after next
|
|
# — the same reason changelog-monotonic guards a property no single tree can
|
|
# be asked about.
|
|
#
|
|
# IT RUNS OFFLINE, AND IT DOES NOT PASS BY ABSENCE. Tests must not need the
|
|
# network, and a guard that goes green when it cannot see its input is the
|
|
# blind-sweep shape this repo keeps writing issues about. So the inventory is
|
|
# read from the document and the tree is scanned for forge markers — both local
|
|
# — and a missing or empty `.upstream-ref` is a REFUSAL, not a skip.
|
|
set -u
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
# shellcheck source=test/harness.sh
|
|
. "$ROOT/test/harness.sh"
|
|
|
|
DOC="$ROOT/docs/UPSTREAM-SYNC.md"
|
|
REF="$ROOT/.upstream-ref"
|
|
|
|
# --- the recorded upstream ref ------------------------------------------------
|
|
|
|
check "the sync document exists" 0 "" test -f "$DOC"
|
|
check "the upstream ref is recorded" 0 "" test -f "$REF"
|
|
|
|
# THE OBJECT IS MANDATORY. An earlier head accepted "UNVERIFIABLE-HERE" when
|
|
# upstream's commit was not in the local store, which contradicted this issue's
|
|
# own "must not pass by absence" and made the check decorative in exactly the
|
|
# environment it matters in (@codex-reviewer-andresmgsl, #200 review).
|
|
#
|
|
# "Runs offline" means the TEST reads local evidence, not that CI may omit the
|
|
# evidence and pass. ci.yml fetches the recorded object before running the
|
|
# suite; if it is missing here, that is a refusal.
|
|
recorded_ref() { grep -vE '^[[:space:]]*(#|$)' "$REF" | head -n1; }
|
|
ref_is_recorded() { [ -n "$(recorded_ref)" ]; }
|
|
check "...and is not blank — an unrecorded ref is a refusal, not a skip" 0 "" \
|
|
ref_is_recorded
|
|
# Full 40, not a short prefix: a short SHA is ambiguous across a growing repo
|
|
# and cannot be compared byte-wise between the runbook, the CHANGELOG and here.
|
|
ref_is_full_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{40}$'; }
|
|
check "...and is a FULL 40-character SHA" 0 "" ref_is_full_sha
|
|
|
|
ref_object_present() { git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; }
|
|
check "the recorded commit is present locally — absent is a refusal, not a skip" 0 "" \
|
|
ref_object_present
|
|
ref_is_ancestor() { git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null; }
|
|
check "...and is an ancestor of HEAD, so main really carries what is recorded" 0 "" \
|
|
ref_is_ancestor
|
|
|
|
# --- the inventory ------------------------------------------------------------
|
|
|
|
# The document's inventory table: the first `code` span of each table row in
|
|
# the "Where the forge delta lives" section.
|
|
inventory() {
|
|
awk '/^## Where the forge delta lives/ { on = 1; next }
|
|
on && /^## / { on = 0 }
|
|
on && /^\| `/ { gsub(/^\| `/, ""); sub(/`.*$/, ""); print }' "$DOC"
|
|
}
|
|
|
|
inventory_size() { inventory | wc -l; }
|
|
inventory_is_populated() { [ "$(inventory_size)" -ge 6 ]; }
|
|
check "the inventory names at least the six known delta files" 0 "" \
|
|
inventory_is_populated
|
|
|
|
# Exact for a file entry, `dir/` + one path separator for a directory entry.
|
|
# Prefix matching accepted `drills-old/x` for `drills/` and `lib/forge.sh.backup`
|
|
# for `lib/forge.sh` (@codex-reviewer-andresmgsl, #200 review).
|
|
in_inventory() { # $1 = repo-relative path
|
|
local entry
|
|
while IFS= read -r entry; do
|
|
[ -n "$entry" ] || continue
|
|
case "$entry" in
|
|
*/) case "$1" in "$entry"*) return 0 ;; esac ;;
|
|
*) [ "$1" = "$entry" ] && return 0 ;;
|
|
esac
|
|
done < <(inventory)
|
|
return 1
|
|
}
|
|
check "a listed file is recognised" 0 "" in_inventory lib/forge.sh
|
|
check "...including one listed as a directory" 0 "" in_inventory drills/0.4.1.md
|
|
check "an unlisted file is not" 1 "" in_inventory lib/version.sh
|
|
|
|
# --- the scan -----------------------------------------------------------------
|
|
|
|
# What "forge-specific" means, mechanically: the selector's own verbs, the
|
|
# environment override that drives them, and — on a workflow, which has no
|
|
# shell to call the selector from — a forge decision written inline.
|
|
#
|
|
# The earlier version scanned only shell under lib/ actions/ bin/ .github/scripts.
|
|
# That missed three categories the inventory itself claims to govern: workflows,
|
|
# .github/labels.conf and drills/. Merged main already had blind spots there —
|
|
# labels.yml decides on GITHUB_SERVER_URL and declares a client, refs-guard.yml
|
|
# carries a positive GitHub-only scheduling decision
|
|
# (@codex-reviewer-andresmgsl, #200 review).
|
|
FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT|GITHUB_SERVER_URL|github\.server_url'
|
|
|
|
# SCAN_ROOT is a parameter so the teeth below can drive the REAL check against
|
|
# a constructed tree. The previous mutation proved the predicates and never
|
|
# invoked no_unlisted — the guard could have been `return 0` and still passed.
|
|
scan_root() { printf '%s' "${SCAN_ROOT:-$ROOT}"; }
|
|
|
|
# DISCOVERY IS DERIVED FROM THE TREE, not from a list of directories, depths
|
|
# and extensions. The earlier version hand-picked five globs and therefore
|
|
# could not see `actions/*/action.yml` — this repository's normal composite
|
|
# structure, where a client declaration is exactly a forge decision — or a
|
|
# workflow written `.yaml` rather than `.yml`. @codex-reviewer-andresmgsl
|
|
# constructed both and the guard stayed 21/21 green, which is the whole
|
|
# argument against maintaining a glob list.
|
|
#
|
|
# So: walk everything, then EXCLUDE by class, and let content classify the
|
|
# rest. Excluding is safer than including because a new file type arrives
|
|
# scanned rather than invisible.
|
|
#
|
|
# test/ the harness stubs and asserts these tokens by design
|
|
# changelog.d/ prose fragments
|
|
# *.md prose. `drills/` stays in the INVENTORY because its records
|
|
# are forge-specific by content, but a record mentioning a
|
|
# selector verb in prose is not a decision, and scanning prose
|
|
# for decisions is the mistake this guard's own comment
|
|
# handling exists to avoid.
|
|
#
|
|
# DISCOVERY IS GIT'S, NOT THE FILESYSTEM'S. An earlier head said "tracked" and
|
|
# used `find`, which walks the working directory and knows nothing about the
|
|
# index. That is not pedantry: `ci.yml` extracts shellcheck and actionlint
|
|
# tarballs INTO the checkout before the suite runs, and any developer cache
|
|
# sits there too. @codex-reviewer-andresmgsl reproduced a false red with one
|
|
# untracked file. `git ls-files -z` makes "tracked" executable rather than
|
|
# prose.
|
|
scanned_paths() {
|
|
local root; root="$(scan_root)"
|
|
git -C "$root" ls-files -z 2>/dev/null \
|
|
| tr '\0' '\n' \
|
|
| grep -vE '^(test/|changelog\.d/)' \
|
|
| grep -vE '\.md$' \
|
|
| sort
|
|
}
|
|
|
|
forge_specific_files() {
|
|
local root rel; root="$(scan_root)"
|
|
while IFS= read -r rel; do
|
|
[ -n "$rel" ] || continue
|
|
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$root/$rel" 2>/dev/null \
|
|
| grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel"
|
|
done < <(scanned_paths)
|
|
return 0
|
|
}
|
|
|
|
# A file that merely CALLS the shim is not where the forge is decided — every
|
|
# reconciler calls forge_preflight and that is the point of the shim. A file
|
|
# that DECLARES a client is a different thing and is NOT exempt: it is a
|
|
# deliberate forge-delta location and belongs in the inventory
|
|
# (@codex-reviewer-andresmgsl).
|
|
SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh
|
|
actions/labels-reconcile/labels-reconcile.sh
|
|
actions/labels-scope/labels-scope.sh
|
|
lib/facts.sh
|
|
lib/ruling.sh
|
|
lib/attention.sh
|
|
.github/workflows/release.yml'
|
|
|
|
declares_a_client() {
|
|
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$(scan_root)/$1" \
|
|
| grep -qE 'CEREMONY_FORGE_CLIENT[=:]'
|
|
}
|
|
|
|
unlisted_deciders() {
|
|
local rel
|
|
while IFS= read -r rel; do
|
|
[ -n "$rel" ] || continue
|
|
if grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && ! declares_a_client "$rel"; then
|
|
continue
|
|
fi
|
|
in_inventory "$rel" && continue
|
|
printf '%s\n' "$rel"
|
|
done < <(forge_specific_files)
|
|
}
|
|
|
|
no_unlisted() {
|
|
local found
|
|
found="$(unlisted_deciders)"
|
|
[ -z "$found" ] || {
|
|
printf 'forge-specific but not in docs/UPSTREAM-SYNC.md:\n' >&2
|
|
printf ' %s\n' "$found" >&2
|
|
return 1
|
|
}
|
|
}
|
|
check "every forge-deciding file is named in the inventory" 0 "" no_unlisted
|
|
|
|
# --- teeth --------------------------------------------------------------------
|
|
# These drive the REAL top-level check against a constructed tree, via
|
|
# SCAN_ROOT. The earlier version asserted the predicates separately and never
|
|
# invoked no_unlisted — so the guard could have been replaced with `return 0`
|
|
# and both "must-fail" rows would still have passed
|
|
# (@codex-reviewer-andresmgsl, #200 review). A mutation test that cannot fail
|
|
# when the thing it guards is deleted is the shape this repo keeps filing
|
|
# issues about.
|
|
|
|
TMP="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
|
|
# A tiny GIT repository, because discovery is git's now: a fixture that is only
|
|
# a directory would be invisible to `ls-files` and every must-fail below would
|
|
# pass vacuously.
|
|
fixture_tree() { # -> a tracked tree with no forge-deciding files
|
|
local t="$TMP/tree"
|
|
rm -rf "$t"
|
|
mkdir -p "$t/lib" "$t/.github/workflows"
|
|
printf '#!/usr/bin/env bash\necho hello\n' >"$t/lib/plain.sh"
|
|
printf 'name: ci\non: [push]\n' >"$t/.github/workflows/plain.yml"
|
|
git -C "$t" init -q 2>/dev/null
|
|
git -C "$t" add -A 2>/dev/null
|
|
printf '%s' "$t"
|
|
}
|
|
track() { git -C "$root" add -A 2>/dev/null; }
|
|
|
|
root="$(fixture_tree)"
|
|
clean_tree_passes() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "the guard is green on a tree with no forge decisions" 0 "" clean_tree_passes
|
|
|
|
# MUST FAIL: a shell file that decides the forge, in no inventory entry.
|
|
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
printf '%s\n' '#!/usr/bin/env bash' 'case "$(forge_detect)" in forgejo) : ;; esac' \
|
|
>"$root/lib/scattered.sh"
|
|
track; scattered_shell() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "a new SHELL file deciding the forge fails the real guard" 1 "" scattered_shell
|
|
check "...and the failure names the file" 1 "lib/scattered.sh" scattered_shell
|
|
rm -f "$root/lib/scattered.sh"; track
|
|
|
|
# MUST FAIL on a NON-SHELL surface too, so coverage cannot regress to the old
|
|
# shell-only glob: a workflow deciding on the server URL.
|
|
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' ' steps:' \
|
|
' - run: [ "$GITHUB_SERVER_URL" = https://github.com ] || exit 0' \
|
|
>"$root/.github/workflows/scattered.yml"
|
|
track; scattered_workflow() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "a new WORKFLOW deciding the forge fails it too" 1 "" scattered_workflow
|
|
check "...naming that file" 1 ".github/workflows/scattered.yml" scattered_workflow
|
|
rm -f "$root/.github/workflows/scattered.yml"; track
|
|
|
|
# @codex-reviewer-andresmgsl's two reproductions, verbatim as fixtures. Both
|
|
# passed the hand-picked-glob version 21/21, which is why discovery is derived
|
|
# from the tree now. Deleting a discovery class must make these red.
|
|
mkdir -p "$root/actions/unlisted-forge-decision"
|
|
printf '%s\n' 'name: x' 'runs:' ' using: composite' ' steps:' \
|
|
' - shell: bash' ' env:' ' CEREMONY_FORGE_CLIENT: gh' \
|
|
' run: true' >"$root/actions/unlisted-forge-decision/action.yml"
|
|
track; composite_action_seen() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "a forge declaration in actions/*/action.yml fails the guard" 1 \
|
|
"actions/unlisted-forge-decision/action.yml" composite_action_seen
|
|
rm -rf "$root/actions/unlisted-forge-decision"; track
|
|
|
|
# ...and a workflow written .yaml rather than .yml — `*.yml` was never a
|
|
# complete workflow surface.
|
|
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
|
|
printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' \
|
|
" if: github.server_url == 'https://github.com'" ' steps: []' \
|
|
>"$root/.github/workflows/unlisted-forge-decision.yaml"
|
|
track; yaml_workflow_seen() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "...and one in a .yaml workflow does too" 1 \
|
|
".github/workflows/unlisted-forge-decision.yaml" yaml_workflow_seen
|
|
rm -f "$root/.github/workflows/unlisted-forge-decision.yaml"; track
|
|
check "...leaving the fixture tree green again" 0 "" clean_tree_passes
|
|
|
|
# A declaration is a delta location even in a file that would otherwise read as
|
|
# a shim consumer, so the consumer allow-list cannot hide one.
|
|
mkdir -p "$root/lib"
|
|
printf '%s\n' '#!/usr/bin/env bash' 'CEREMONY_FORGE_CLIENT=gh' >"$root/lib/facts.sh"
|
|
track; declared_not_exempt() { SCAN_ROOT="$root" no_unlisted; }
|
|
check "a shim consumer that DECLARES a client is not exempt" 1 "lib/facts.sh" \
|
|
declared_not_exempt
|
|
rm -f "$root/lib/facts.sh"; track
|
|
track
|
|
check "...and the tree is green again once it is gone" 0 "" clean_tree_passes
|
|
|
|
# UNTRACKED input is not source. ci.yml extracts shellcheck and actionlint
|
|
# tarballs into the checkout before the suite runs, and a developer cache sits
|
|
# there too; parsing either is a false red on something outside the repository
|
|
# property (@codex-reviewer-andresmgsl, #200 review — reproduced with one file).
|
|
printf 'CEREMONY_FORGE_CLIENT=gh\n' >"$root/local-tool-cache.txt"
|
|
check "an UNTRACKED marker-bearing file is ignored" 0 "" clean_tree_passes
|
|
check "...and is still ignored once it carries a decision" 0 "" clean_tree_passes
|
|
track
|
|
check "...but the moment it is TRACKED the guard sees it" 1 "local-tool-cache.txt" \
|
|
clean_tree_passes
|
|
git -C "$root" rm -q --cached local-tool-cache.txt 2>/dev/null
|
|
rm -f "$root/local-tool-cache.txt"
|
|
check "...and removing it restores green" 0 "" clean_tree_passes
|
|
|
|
# Path matching, both boundaries.
|
|
check "a directory entry does not match a sibling with the same prefix" 1 "" \
|
|
in_inventory drills-old/0.4.1.md
|
|
check "a file entry does not match a longer path" 1 "" in_inventory lib/forge.sh.backup
|
|
check "...while the real ones still match" 0 "" in_inventory drills/0.4.1.md
|
|
|
|
summary
|