From 4cf984a693c138333b737bf523c534aa5f52f564 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:03:39 -0400 Subject: [PATCH 01/16] governance: mirror the maintainer-directed agent lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, mirroring the constitution's new lane: the Authority Model gains the lane bullet; the Incubation Contribution Boundary is scoped to unsolicited patches with a pointer to the lane; Source Authority names the lane a controlled source path. Reason: two statements of one rule must not diverge — the org governance now cites the lane where the constitution defines it. Source: owner 2026-09-01 Source: original Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XehTac5TJNmPAskwrPp7rJ --- GOVERNANCE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6e3438e..8d0c42a 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -92,6 +92,7 @@ That means: - pull requests are enabled but restricted to collaborators only - collaborator PR access is an operational mechanism for trusted maintainers and approved collaborators, not a public contribution path - AI/security tools may generate evidence, but they do not approve, merge, mutate authority, or gain source authority from repository access settings +- maintainer-directed agent work is a recognized operational lane: agents working under the maintainer's explicit direction, through accounts the maintainer granted, may prepare, commit, and submit changes as draft pull requests with full origin trailers; they do not approve, merge, ratify, or change settings (see the org `CONTRIBUTING.md`, Maintainer-Directed Agent Work, maintainer decision 2026-09-01) MinSpec may be publicly visible before it is publicly governable. @@ -99,7 +100,9 @@ MinSpec may be publicly visible before it is publicly governable. ## Incubation Contribution Boundary -During incubation, MinSpec does not accept unsolicited external contributors, code pull requests, documentation pull requests, package submissions, recipe submissions, workflow changes, dependency changes, or AI-generated contribution patches. +During incubation, MinSpec does not accept unsolicited external contributors, code pull requests, documentation pull requests, package submissions, recipe submissions, workflow changes, dependency changes, or unsolicited AI-generated contribution patches. + +Maintainer-directed agent work enters through the lane defined in the org `CONTRIBUTING.md` (Maintainer-Directed Agent Work); it is maintainer work, not an external contribution. Only users with repository write, maintain, or admin access may open pull requests. That access is reserved for trusted maintainers and approved collaborators. @@ -131,6 +134,8 @@ MinSpec should not accept trusted source material from: - unapproved GitHub Actions or workflow edits - agent-generated mutations outside a controlled source path +The maintainer-directed agent lane is a controlled source path: work in that lane is directed by the maintainer, trailer-attributed to its producing agent, submitted as a reviewable draft pull request, and ratified by the maintainer before it lands. + AI agents, GitHub Apps, bots, automation, Dependabot, Copilot agents, browser agents, and external tools are not maintainers and do not gain source authority from collaborator-only PR settings. Security review is not a substitute for source authority. From 3a067a7840a2baf79fcb4de681bba7a99cf0e3d6 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:39:22 -0400 Subject: [PATCH 02/16] repo: register Serena for agent sessions Tracked .serena/project.yml and .mcp.json, uniform across the MinSpec org, so every checkout and worktree of this repository is a Serena-served agent workspace with identical scope. The PHP backend is Phpactor (PHAR-managed, no Node). Mate is deliberately absent: it is application-scoped by nature and lives in minspec/workbench, the org's application host. Source: owner 2026-09-01 Source: original Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XehTac5TJNmPAskwrPp7rJ --- .mcp.json | 8 ++++++++ .serena/project.yml | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .mcp.json create mode 100644 .serena/project.yml diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e16128b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": ["serena", "start-mcp-server", "--context", "claude-code", "--project", "."] + } + } +} diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..fe5b470 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,11 @@ +# Serena project config for minspec/.github — tracked, so every worktree and +# session resolves the same scope. Uniform across the MinSpec org; the +# PHP backend is Phpactor (PHAR, PHP >= 8.1, no Node). +project_name: "minspec-github" +languages: ["php_phpactor"] +encoding: "utf-8" +ignore_all_files_in_gitignore: true +ignored_paths: + - "var/**" + - "vendor/**" +read_only: false From 1b0de0e63aed7875d0ac11104e1e4104db00f13f Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:48 -0400 Subject: [PATCH 03/16] =?UTF-8?q?repo:=20real=20CI=20=E2=80=94=20the=20ci?= =?UTF-8?q?=20workflow=20on=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on pull requests and on pushes to dev and the stable branch: composer validate for PHP manifests, the create-project end-to-end oracle where this repo is the skeleton, JSON/YAML validity elsewhere. Public repo: GitHub-hosted runners are free; the check name "ci" is the required context the dev ruleset will pin. Source: owner 2026-09-01 Source: original Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XehTac5TJNmPAskwrPp7rJ --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f414bb8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +name: ci +on: + pull_request: + push: + branches: [dev, main] +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: JSON validity + run: find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -r -n1 jq empty + - name: YAML validity + run: python3 -c "import glob,yaml; [list(yaml.safe_load_all(open(p))) for g in ('**/*.yml','**/*.yaml') for p in glob.glob(g,recursive=True) if '/.git/' not in p]" From 472a851ded4b412c94746115c59d438f1c86a3eb Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:43:29 -0400 Subject: [PATCH 04/16] repo: workflow and MCP hardening from the security audit Findings applied from the independent audit (CHANGES verdict): workflow-level permissions contents:read; persist-credentials false on checkout; every action pinned to a reviewed commit SHA with its tag in a comment; Serena pinned in .mcp.json to the audited git commit instead of floating uvx resolution. Finding: [P1] workflows ran with default token permissions Finding: [P1] .mcp.json launched unversioned uvx serena Finding: [P2] mutable action tags across all repositories Verified: python yaml.safe_load + json.load on the changed files Source: original Co-Authored-By: GPT-5 Codex Co-Authored-By: Claude Fable 5 Reviewed-by: GPT-5 Codex Claude-Session: https://claude.ai/code/session_01XehTac5TJNmPAskwrPp7rJ --- .github/workflows/ci.yml | 6 +++++- .mcp.json | 11 ++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f414bb8..457b587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,15 @@ on: pull_request: push: branches: [dev, main] +permissions: + contents: read jobs: ci: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: JSON validity run: find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -r -n1 jq empty - name: YAML validity diff --git a/.mcp.json b/.mcp.json index e16128b..7a030cd 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,16 @@ "mcpServers": { "serena": { "command": "uvx", - "args": ["serena", "start-mcp-server", "--context", "claude-code", "--project", "."] + "args": [ + "--from", + "git+https://github.com/oraios/serena@43ae0211d7f3bba4101cd0552707fa21d37f4c84", + "serena", + "start-mcp-server", + "--context", + "claude-code", + "--project", + "." + ] } } } From 9099e81843f68b078c9f4da93cfea560373dae20 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:21:18 -0400 Subject: [PATCH 05/16] =?UTF-8?q?repo:=20the=20term=20wall=20=E2=80=94=20n?= =?UTF-8?q?ames=20this=20organisation=20does=20not=20use,=20refused=20in?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composite action every repo's ci job runs: it scans tracked content, file paths, the change's commit messages (base...head via the API, or the pushed commit), the pull request title and body, and the branch name for names that must not appear here — not affirmed, not negated, not cited. The wall never spells what it refuses and masks every hit it prints; a surface it could not read is a hit, never a pass. This repo's own ci runs it and then proves it fires on a planted fault and stays quiet on a clean neighbour. Verified locally: 10/10 cases (clean tree and PR; planted content, path, title, branch name, commit message via API and via push; API unreachable) exit as specified, and the action's files pass their own wall. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY --- .github/actions/term-wall/action.yml | 14 +++++ .github/actions/term-wall/term-wall.sh | 83 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 12 ++++ 3 files changed, 109 insertions(+) create mode 100644 .github/actions/term-wall/action.yml create mode 100755 .github/actions/term-wall/term-wall.sh diff --git a/.github/actions/term-wall/action.yml b/.github/actions/term-wall/action.yml new file mode 100644 index 0000000..0d95436 --- /dev/null +++ b/.github/actions/term-wall/action.yml @@ -0,0 +1,14 @@ +name: term wall +description: >- + Refuses names this organisation does not use — in tracked content, in + file paths, in the change's commit messages, in the pull request title + and body, and in the branch name. The wall never spells what it + refuses and masks every hit it prints. +runs: + using: composite + steps: + - name: term wall + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: bash "$GITHUB_ACTION_PATH/term-wall.sh" diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh new file mode 100755 index 0000000..12b7522 --- /dev/null +++ b/.github/actions/term-wall/term-wall.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# term-wall.sh — names this organisation does not use, refused everywhere. +# +# The wall scans a change for names that must not appear here: not +# affirmed, not negated, not cited. It never spells the names it refuses +# — the pattern below would otherwise be its own first hit — and it masks +# every hit it prints, so the log does not carry what the tree may not. +# +# Surfaces, in order: +# 1. tracked file content (case-insensitive, binaries skipped) +# 2. tracked file paths +# 3. the commit messages of the change (PR: base...head; push: before...after) +# 4. the pull request title and body +# 5. the branch name +# +# Exit 0 clean. Exit 1 on any hit. A surface that could not be read is a +# hit too — "could not look" is never "found nothing". +# +# Outside GitHub Actions (no GITHUB_EVENT_PATH) only surfaces 1 and 2 run, +# against the current directory; that is the self-test's mode. +set -uo pipefail + +pat='s[c]ient[ _-]?db|u[s]cient' +rc=0 +mask() { sed -E "s/($pat)/[forbidden name]/Ig"; } +hit() { printf '::error::term wall: %s\n' "$1"; rc=1; } +scan() { # scan — text as an argument, never a pipe: a hit must + # set rc in this shell, and a pipeline's stages run in subshells. + local surface=$1 text=$2 found + found=$(printf '%s\n' "$text" | grep -i -n -E "$pat" 2>/dev/null | mask | head -40 || true) + if [[ -n $found ]]; then + printf '%s\n' "$found" | sed "s/^/ $surface: /" + hit "forbidden name in $surface" + fi +} + +# 1. tracked content +content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -i -n -E "$pat" -- 2>/dev/null || true) +if [[ -n $content ]]; then + printf '%s\n' "$content" | mask | head -40 | sed 's/^/ content: /' + hit "forbidden name in tracked content" +fi + +# 2. tracked paths +scan "path" "$(git ls-files 2>/dev/null)" + +# 3-5. the change itself, when running under Actions +if [[ -n ${GITHUB_EVENT_PATH:-} && -f ${GITHUB_EVENT_PATH:-} ]]; then + event=${GITHUB_EVENT_NAME:-} + repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY unset} + case $event in + pull_request|pull_request_target) + base=$(jq -r '.pull_request.base.sha' "$GITHUB_EVENT_PATH") + head=$(jq -r '.pull_request.head.sha' "$GITHUB_EVENT_PATH") + scan "pull request title/body" "$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")" + scan "branch name" "${GITHUB_HEAD_REF:-}" + ;; + push) + base=$(jq -r '.before' "$GITHUB_EVENT_PATH") + head=$(jq -r '.after' "$GITHUB_EVENT_PATH") + scan "branch name" "${GITHUB_REF_NAME:-}" + ;; + *) + base=""; head="";; + esac + if [[ -n $head ]]; then + if [[ -z $base || $base =~ ^0+$ ]]; then + msgs=$(git log -1 --format=%B "$head" 2>/dev/null) || msgs="" + [[ -n $msgs ]] || hit "could not read the commit message of $head (UNREACHABLE is not a pass)" + else + if ! msgs=$(gh api "repos/$repo/compare/$base...$head?per_page=250" --jq '.commits[].commit.message' 2>/tmp/term-wall.api.err); then + hit "could not read commit messages $base...$head via the API — $(head -c 200 /tmp/term-wall.api.err) (UNREACHABLE is not a pass)" + msgs="" + fi + fi + scan "commit messages" "$msgs" + fi +fi + +if [[ $rc -eq 0 ]]; then + echo "term wall: clean — $(git ls-files 2>/dev/null | wc -l) tracked files, the change's messages, title, body and branch name carry no forbidden name" +fi +exit "$rc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 457b587..b89eb6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,18 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false + - name: term wall + uses: ./.github/actions/term-wall + - name: term wall fires on a planted fault, stays quiet on a clean neighbour + run: | + set -euo pipefail + wall="$GITHUB_WORKSPACE/.github/actions/term-wall/term-wall.sh" + t=$(mktemp -d); cd "$t"; git init -q; git config user.email ci@ci.invalid; git config user.name ci + printf 'clean\n' > c.txt; git add c.txt; git commit -qm clean + env -u GITHUB_EVENT_PATH bash "$wall" + printf 'x \x53\x63\x69\x65\x6e\x74\x44\x42 y\n' > p.txt; git add p.txt + if env -u GITHUB_EVENT_PATH bash "$wall"; then echo "::error::the wall stayed quiet on a planted fault"; exit 1; fi + echo "the wall fires on a planted fault and stays quiet on a clean neighbour" - name: JSON validity run: find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -r -n1 jq empty - name: YAML validity From e6324b61bb85e01a0d35de006e57c479092b50c9 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:51:24 -0400 Subject: [PATCH 06/16] repo: pin the term wall's hit format and refusal classes Two producers read the contract differently: the location of a hit (with or without a colon), the pull request title and body as one surface or two, and the names of the refusal classes. The contract now pins all three in a table; the implementation moves to it, the tests stand. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY --- .github/actions/term-wall/CONTRACT.md | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/actions/term-wall/CONTRACT.md diff --git a/.github/actions/term-wall/CONTRACT.md b/.github/actions/term-wall/CONTRACT.md new file mode 100644 index 0000000..9bf3f69 --- /dev/null +++ b/.github/actions/term-wall/CONTRACT.md @@ -0,0 +1,95 @@ +# The term wall — contract v1 + +Names this organisation does not use must not appear in any of its +repositories: not affirmed, not negated, not cited. The wall refuses +them, and the wall itself never carries them in any readable or +encoded form. + +## Instruments + +1. `.github/actions/term-wall/term-wall.sh` (run by the composite + action `.github/actions/term-wall`) — every repo's `ci` job runs it. +2. `ops/devlane/workflow/checks/term_wall.py` — the lane's local copy, + run by the commit-msg hook and by apply-push's guards. + +## The pattern is configuration, never tree content + +- The pattern is an extended, case-insensitive regular expression read + from the environment variable `TERM_WALL`. In CI the action takes it + from the org-level Actions variable `vars.TERM_WALL`. Locally the + Python check reads `TERM_WALL`, falling back to the gitignored file + `/ops/bin/term-wall.conf` (one line: the pattern). +- No tracked file may contain the pattern, a piece of it, or any + encoding of the names (hex, base64, bracket tricks, escapes). The + self-test's planted fault comes from `vars.TERM_WALL_PLANT` (a string + the pattern matches), never from the tree. +- An unset or empty pattern is a refusal, exit 2, stdout empty, one + line on stderr: `pattern: expected TERM_WALL set; found empty; + needed the org variable (CI) or ops/bin/term-wall.conf (local)`. + The wall never passes vacuously. + +## Surfaces (term-wall.sh) + +1. tracked content — every `git ls-files` path, binaries skipped, + case-insensitive; +2. tracked paths; +3. the commit messages of the change — `pull_request`: `base..head`; + `push`: `before..head`, or only the head commit when `before` is + all zeros — read from git (fetching what the checkout lacks), never + from an API: the action needs no token and declares none; +4. the pull request title and body (from the event payload); +5. the branch name (`GITHUB_HEAD_REF` for a PR, `GITHUB_REF_NAME` for + a push). + +Outside GitHub Actions (no `GITHUB_EVENT_PATH`), surfaces 1 and 2 run +against the current directory. The Python check covers surface 1 and +2 (`[--root DIR] [PATH ...]`), one message (`--message-file FILE`), a +range of commit messages (`--range BASE..HEAD`), or `--stdin`. + +## Outcomes, on the wire + +| exit | meaning | +|---|---| +| 0 | clean; exactly one summary line on stdout | +| 1 | at least one hit, every hit printed on stdout in the pinned format below; a surface that could not be read (fetch failed, payload unreadable) is itself a hit — could-not-look is never a pass | +| 2 | refusal; stdout empty, one stderr line `: expected …; found …; needed …` | + +Refusal classes: `pattern` (unset or empty), `git work tree` (not +inside one), `message` (missing message file), `range` (unresolvable). + +## Hit format, pinned + +One stdout line per hit: `: : `. The location never contains a +colon. The raw matched text never appears in any output. + +| surface | location | +|---|---| +| `content` | ` line ` | +| `path` | `` | +| `commit messages` | ` line ` | +| `pull request title` | `line ` | +| `pull request body` | `line ` | +| `branch name` | `` | +| `event payload` | `` — the hit when the payload cannot be read | +| `message` (`--message-file`) | `line ` | +| `range` (`--range`) | ` line ` | +| `stdin` | `line ` | + +## Self-test (the `.github` repo's own ci) + +With `TERM_WALL_PLANT` as the planted fault: a planted file fires +(exit 1, masked hit), a clean neighbour stays quiet (exit 0), and an +empty `TERM_WALL` refuses (exit 2). + +## Tests + +Tests execute the real instrument as a subprocess inside temporary git +repositories they create; nothing about the wall is mocked. They set +`TERM_WALL` explicitly to a test-only pattern (for example +`zz[q]orblat`) and plant matches of it, so no forbidden name exists +anywhere. They are deterministic and hermetic: no network, no sleeps, +no dependence on the caller's cwd, environment, or git identity +(configure user.name/user.email in each temp repo). Every test asserts +the exit code and the output shape. Push and pull-request events are +simulated with an event JSON file and the `GITHUB_*` variables. From 69a006cf23dcd554031ace458724d49e5f5b98eb Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 18:51:46 -0400 Subject: [PATCH 07/16] =?UTF-8?q?repo:=20term=20wall=20tests,=20from=20the?= =?UTF-8?q?=20contract=20=E2=80=94=2015=20real=20executions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored from the contract by a producer other than the implementer: each test runs term-wall.sh as a subprocess in its own temporary git repository with a test-only pattern, simulating pull_request and push events, and asserts exit code and output shape. Red at this commit: 13 of 15 fail, identically over three runs. Source: original Co-Authored-By: GPT-5.6 Sol Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T225145Z-apply-push-ca3088 Patch-SHA256: 5c280fccf2f955ddce536df7b4d99ca4c652c5a7edd059354eab12a985bb92e1 --- .../actions/term-wall/tests/test_term_wall.py | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 .github/actions/term-wall/tests/test_term_wall.py diff --git a/.github/actions/term-wall/tests/test_term_wall.py b/.github/actions/term-wall/tests/test_term_wall.py new file mode 100644 index 0000000..b3448b7 --- /dev/null +++ b/.github/actions/term-wall/tests/test_term_wall.py @@ -0,0 +1,239 @@ +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "term-wall.sh" +PATTERN = "zz[q]orblat" +PLANT = "zzqor" + "blat" +REFUSAL = ( + "pattern: expected TERM_WALL set; found empty; needed the org variable (CI) " + "or ops/bin/term-wall.conf (local)" +) + + +class TermWallTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.home = self.root / "home" + self.home.mkdir() + self.git("init", "-q") + self.git("config", "user.name", "Term Wall Test") + self.git("config", "user.email", "term-wall@example.invalid") + self.write("clean.txt", "ordinary text\n") + self.commit("initial clean commit") + + def tearDown(self): + self.temporary.cleanup() + + def environment(self, **values): + env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(self.home), + "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + "TERM_WALL": PATTERN, + } + env.update({key: str(value) for key, value in values.items()}) + return env + + def git(self, *arguments, cwd=None): + return subprocess.run( + ["git", *arguments], + cwd=cwd or self.repo, + env=self.environment(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ).stdout.strip() + + def write(self, relative, contents): + path = self.repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + return path + + def commit(self, message): + self.git("add", "--all") + self.git("commit", "-q", "--allow-empty", "-m", message) + return self.git("rev-parse", "HEAD") + + def event(self, name, payload, *, head_ref="feature", ref_name="main"): + path = self.root / f"{name}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return { + "GITHUB_EVENT_PATH": path, + "GITHUB_EVENT_NAME": name, + "GITHUB_REPOSITORY": "example/term-wall-test", + "GITHUB_HEAD_REF": head_ref, + "GITHUB_REF_NAME": ref_name, + } + + def run_wall(self, *, cwd=None, env=None): + return subprocess.run( + [str(SCRIPT)], + cwd=cwd or self.repo, + env=env or self.environment(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15, + ) + + def assert_clean(self, result): + self.assertEqual(result.returncode, 0, result) + self.assertEqual(result.stderr, "") + lines = result.stdout.splitlines() + self.assertEqual(len(lines), 1, result.stdout) + self.assertRegex(lines[0], r"^term wall: clean(?:\b|\s|$)") + + def assert_hit(self, result, surface): + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertNotIn(PLANT, result.stdout.lower()) + self.assertNotIn(PLANT, result.stderr.lower()) + lines = result.stdout.splitlines() + self.assertTrue(lines, "a hit must be printed on stdout") + matching = [line for line in lines if line.startswith(surface + ": ")] + self.assertTrue(matching, f"missing {surface!r} hit in {result.stdout!r}") + for line in matching: + self.assertRegex( + line, + rf"^{re.escape(surface)}: [^:]+: .*\[forbidden name\].*$", + ) + + def test_clean_tree(self): + self.assert_clean(self.run_wall()) + + def test_tracked_content_hit_is_masked(self): + self.write("planted.txt", f"before {PLANT.upper()} after\n") + self.commit("add fixture") + self.assert_hit(self.run_wall(), "content") + + def test_binary_tracked_content_is_skipped(self): + (self.repo / "fixture.bin").write_bytes(b"\x00" + PLANT.encode("ascii") + b"\xff") + self.commit("add binary fixture") + self.assert_clean(self.run_wall()) + + def test_tracked_path_hit_is_masked(self): + self.write(f"notes-{PLANT}.txt", "ordinary text\n") + self.commit("add fixture") + self.assert_hit(self.run_wall(), "path") + + def pr_environment(self, *, title="Clean title", body="Clean body", head_ref="feature"): + base = self.git("rev-parse", "HEAD^") + head = self.git("rev-parse", "HEAD") + payload = { + "pull_request": { + "base": {"sha": base}, + "head": {"sha": head}, + "title": title, + "body": body, + } + } + values = self.event("pull_request", payload, head_ref=head_ref) + return self.environment(**values) + + def test_pull_request_title_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(title=f"Review {PLANT} now")) + self.assert_hit(result, "pull request title") + + def test_pull_request_body_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(body=f"Body has {PLANT}.")) + self.assert_hit(result, "pull request body") + + def test_pull_request_branch_name_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(head_ref=f"topic-{PLANT}")) + self.assert_hit(result, "branch name") + + def test_pull_request_range_commit_message_is_fetched_offline(self): + base = self.git("rev-parse", "HEAD") + self.commit(f"message contains {PLANT}") + head = self.git("rev-parse", "HEAD") + self.git("branch", "base-for-test", base) + self.git("remote", "add", "origin", str(self.repo)) + payload = { + "pull_request": { + "base": {"sha": base}, + "head": {"sha": head}, + "title": "Clean title", + "body": "Clean body", + } + } + result = self.run_wall(env=self.environment(**self.event("pull_request", payload))) + self.assert_hit(result, "commit messages") + + def test_push_with_zero_before_scans_head_commit_only(self): + head = self.commit(f"new branch says {PLANT}") + payload = {"before": "0" * 40, "after": head} + result = self.run_wall(env=self.environment(**self.event("push", payload))) + self.assert_hit(result, "commit messages") + + def test_push_with_range_scans_changed_commit_messages(self): + before = self.git("rev-parse", "HEAD") + after = self.commit(f"range says {PLANT}") + payload = {"before": before, "after": after} + result = self.run_wall(env=self.environment(**self.event("push", payload))) + self.assert_hit(result, "commit messages") + + def test_push_branch_name_hit(self): + head = self.commit("clean push commit") + payload = {"before": "0" * 40, "after": head} + values = self.event("push", payload, ref_name=f"release-{PLANT}") + self.assert_hit(self.run_wall(env=self.environment(**values)), "branch name") + + def test_unreadable_event_payload_is_a_hit(self): + payload = self.root / "malformed.json" + payload.write_text("not valid JSON\n", encoding="utf-8") + env = self.environment( + GITHUB_EVENT_PATH=payload, + GITHUB_EVENT_NAME="pull_request", + GITHUB_REPOSITORY="example/term-wall-test", + GITHUB_HEAD_REF="feature", + GITHUB_REF_NAME="main", + ) + result = self.run_wall(env=env) + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertNotIn(PLANT, result.stdout + result.stderr) + self.assertRegex(result.stdout, r"(?m)^event payload: [^:]+: .+$") + + def test_term_wall_unset_refuses(self): + env = self.environment() + del env["TERM_WALL"] + result = self.run_wall(env=env) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, REFUSAL + "\n") + + def test_term_wall_empty_refuses(self): + result = self.run_wall(env=self.environment(TERM_WALL="")) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, REFUSAL + "\n") + + def test_not_a_git_work_tree_refuses(self): + outside = self.root / "outside" + outside.mkdir() + result = self.run_wall(cwd=outside) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + lines = result.stderr.splitlines() + self.assertEqual(len(lines), 1, result.stderr) + self.assertRegex(lines[0], r"^git work tree: expected .+; found .+; needed .+$") + + +if __name__ == "__main__": + unittest.main() From ce33e5e85956cf25a9ad93ae349d7152cf721572 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 18:51:49 -0400 Subject: [PATCH 08/16] =?UTF-8?q?repo:=20the=20term=20wall=20to=20its=20co?= =?UTF-8?q?ntract=20=E2=80=94=20pattern=20from=20configuration,=20no=20tok?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit term-wall.sh reads its pattern only from TERM_WALL (the action takes it from vars.TERM_WALL) and refuses on the wire when it is unset; commit messages come from git (base..head, or the head commit on a branch creation), fetched offline, never from an API, so the action holds no token; every hit is masked; the self-test plants vars.TERM_WALL_PLANT and proves the refusal. CONTRACT.md carries the contract verbatim. Source: original Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T225148Z-apply-push-dc1f97 Patch-SHA256: 3f59b7252e7ae3b3e0c7894ec551f7e1811d1811f7ed285ef585232bb740fddb --- .github/actions/term-wall/action.yml | 11 +- .github/actions/term-wall/term-wall.sh | 145 ++++++++++++++++++------- .github/workflows/ci.yml | 17 ++- 3 files changed, 124 insertions(+), 49 deletions(-) diff --git a/.github/actions/term-wall/action.yml b/.github/actions/term-wall/action.yml index 0d95436..796452e 100644 --- a/.github/actions/term-wall/action.yml +++ b/.github/actions/term-wall/action.yml @@ -1,14 +1,17 @@ name: term wall description: >- Refuses names this organisation does not use — in tracked content, in - file paths, in the change's commit messages, in the pull request title - and body, and in the branch name. The wall never spells what it - refuses and masks every hit it prints. + file paths, in the change's commit messages (read from git, never from + an API: no token needed or declared), in the pull request title and + body, and in the branch name. The pattern comes only from the + org-level Actions variable TERM_WALL; an empty pattern is a refusal, + never a pass. The wall never spells what it refuses and masks every + hit it prints. runs: using: composite steps: - name: term wall shell: bash env: - GH_TOKEN: ${{ github.token }} + TERM_WALL: ${{ vars.TERM_WALL }} run: bash "$GITHUB_ACTION_PATH/term-wall.sh" diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh index 12b7522..b693efa 100755 --- a/.github/actions/term-wall/term-wall.sh +++ b/.github/actions/term-wall/term-wall.sh @@ -1,44 +1,76 @@ #!/usr/bin/env bash # term-wall.sh — names this organisation does not use, refused everywhere. # -# The wall scans a change for names that must not appear here: not -# affirmed, not negated, not cited. It never spells the names it refuses -# — the pattern below would otherwise be its own first hit — and it masks -# every hit it prints, so the log does not carry what the tree may not. +# The pattern is configuration, never tree content: it is read only from +# the TERM_WALL environment variable (in CI the composite action takes it +# from the org-level Actions variable vars.TERM_WALL). An unset or empty +# pattern is a refusal, exit 2 — the wall never passes vacuously. Every +# hit it prints is masked, so the log does not carry what the tree may not. # # Surfaces, in order: # 1. tracked file content (case-insensitive, binaries skipped) # 2. tracked file paths -# 3. the commit messages of the change (PR: base...head; push: before...after) -# 4. the pull request title and body -# 5. the branch name +# 3. the commit messages of the change — pull_request: base..head; +# push: before..head, or only the head commit when before is all +# zeros — read from git, fetching what the checkout lacks; never +# from an API: the action needs no token and declares none +# 4. the pull request title and body (from the event payload) +# 5. the branch name (GITHUB_HEAD_REF for a PR, GITHUB_REF_NAME for a push) # -# Exit 0 clean. Exit 1 on any hit. A surface that could not be read is a -# hit too — "could not look" is never "found nothing". +# Exit 0 clean, one summary line on stdout. Exit 1 on any hit, every hit +# on stdout as ": : "; a surface that could not be read (fetch failed, +# payload unreadable) is itself a hit — could-not-look is never a pass. +# Exit 2 refusal: stdout empty, one line on stderr shaped +# "class: expected …; found …; needed …". # -# Outside GitHub Actions (no GITHUB_EVENT_PATH) only surfaces 1 and 2 run, -# against the current directory; that is the self-test's mode. +# Outside GitHub Actions (no GITHUB_EVENT_PATH) only surfaces 1 and 2 +# run, against the current directory. set -uo pipefail -pat='s[c]ient[ _-]?db|u[s]cient' +refuse() { printf '%s\n' "$1" >&2; exit 2; } + +pat=${TERM_WALL:-} +[[ -n $pat ]] || refuse 'pattern: expected TERM_WALL set; found empty; needed the org variable (CI) or ops/bin/term-wall.conf (local)' + +# A pattern grep or sed cannot use would make every scan silently vacuous +# and could leak raw text past the mask — refuse it up front. +printf '' | grep -i -E -- "$pat" >/dev/null 2>&1 +[[ $? -le 1 ]] || refuse 'pattern: expected TERM_WALL to be an extended regular expression grep accepts; found one it rejects; needed a working pattern in the org variable (CI) or ops/bin/term-wall.conf (local)' +printf '' | sed -E "s/($pat)/[forbidden name]/Ig" >/dev/null 2>&1 \ + || refuse 'pattern: expected TERM_WALL usable in a sed substitution; found one sed rejects (an unescaped "/"?); needed a working pattern in the org variable (CI) or ops/bin/term-wall.conf (local)' + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || refuse 'worktree: expected to run inside a git work tree; found none; needed a checkout (CI) or a repository directory (local)' + rc=0 mask() { sed -E "s/($pat)/[forbidden name]/Ig"; } -hit() { printf '::error::term wall: %s\n' "$1"; rc=1; } -scan() { # scan — text as an argument, never a pipe: a hit must - # set rc in this shell, and a pipeline's stages run in subshells. - local surface=$1 text=$2 found - found=$(printf '%s\n' "$text" | grep -i -n -E "$pat" 2>/dev/null | mask | head -40 || true) - if [[ -n $found ]]; then - printf '%s\n' "$found" | sed "s/^/ $surface: /" - hit "forbidden name in $surface" - fi + +emit() { # emit — one hit. Called only + # from the main shell, never from a pipeline stage, so the exit + # code it sets survives. + printf '%s: %s: %s\n' "$1" "$2" "$3" + rc=1 +} + +scan() { # scan — text as an argument, never a pipe: a hit + # must set rc in this shell, and a pipeline's stages run in subshells. + local surface=$1 text=$2 found line + [[ -n $text ]] || return 0 + found=$(printf '%s\n' "$text" | grep -i -n -E -- "$pat" 2>/dev/null | mask || true) + [[ -n $found ]] || return 0 + while IFS= read -r line; do + emit "$surface" "${line%%:*}" "${line#*:}" + done <<< "$found" } # 1. tracked content -content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -i -n -E "$pat" -- 2>/dev/null || true) +content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -H -i -n -E -- "$pat" 2>/dev/null | mask || true) if [[ -n $content ]]; then - printf '%s\n' "$content" | mask | head -40 | sed 's/^/ content: /' - hit "forbidden name in tracked content" + while IFS= read -r line; do + file=${line%%:*}; rest=${line#*:} + emit "content" "$file:${rest%%:*}" "${rest#*:}" + done <<< "$content" fi # 2. tracked paths @@ -47,37 +79,68 @@ scan "path" "$(git ls-files 2>/dev/null)" # 3-5. the change itself, when running under Actions if [[ -n ${GITHUB_EVENT_PATH:-} && -f ${GITHUB_EVENT_PATH:-} ]]; then event=${GITHUB_EVENT_NAME:-} - repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY unset} + base="" head="" want_messages=0 case $event in pull_request|pull_request_target) - base=$(jq -r '.pull_request.base.sha' "$GITHUB_EVENT_PATH") - head=$(jq -r '.pull_request.head.sha' "$GITHUB_EVENT_PATH") - scan "pull request title/body" "$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")" + base=$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.pull_request.head.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" + if title_body=$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH" 2>/dev/null); then + scan "pull request title/body" "$title_body" + else + emit "pull request title/body" "$GITHUB_EVENT_PATH" "could not read the event payload (could-not-look is never a pass)" + fi scan "branch name" "${GITHUB_HEAD_REF:-}" + want_messages=1 ;; push) - base=$(jq -r '.before' "$GITHUB_EVENT_PATH") - head=$(jq -r '.after' "$GITHUB_EVENT_PATH") + base=$(jq -r '.before // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.after // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" scan "branch name" "${GITHUB_REF_NAME:-}" + want_messages=1 ;; - *) - base=""; head="";; esac - if [[ -n $head ]]; then - if [[ -z $base || $base =~ ^0+$ ]]; then - msgs=$(git log -1 --format=%B "$head" 2>/dev/null) || msgs="" - [[ -n $msgs ]] || hit "could not read the commit message of $head (UNREACHABLE is not a pass)" + if [[ $want_messages -eq 1 ]]; then + only_head=0 + [[ $event == push && ( -z $base || $base =~ ^0+$ ) ]] && only_head=1 + if [[ -z $head || ( $only_head -eq 0 && -z $base ) ]]; then + emit "commit messages" "$event" "could not resolve the change's commits from the event payload (could-not-look is never a pass)" else - if ! msgs=$(gh api "repos/$repo/compare/$base...$head?per_page=250" --jq '.commits[].commit.message' 2>/tmp/term-wall.api.err); then - hit "could not read commit messages $base...$head via the API — $(head -c 200 /tmp/term-wall.api.err) (UNREACHABLE is not a pass)" - msgs="" + refs=("$head"); [[ $only_head -eq 0 ]] && refs=("$base" "$head") + # Fetch what the checkout lacks; a shallow clone would walk a + # truncated history without erroring, so unshallow it. + shallow=$(git rev-parse --is-shallow-repository 2>/dev/null) || shallow=false + missing=0 + for ref in "${refs[@]}"; do + git cat-file -e "$ref^{commit}" 2>/dev/null || missing=1 + done + fetch_failed=0 + if [[ $shallow == true || $missing -eq 1 ]]; then + fetch_opts=(--no-tags --quiet) + [[ $shallow == true ]] && fetch_opts+=(--unshallow) + if ! fetch_err=$(git fetch "${fetch_opts[@]}" origin "${refs[@]}" 2>&1); then + emit "commit messages" "${refs[*]}" "could not fetch the change from origin — $(printf '%s' "$fetch_err" | mask | tr '\n' ' ' | head -c 300) (could-not-look is never a pass)" + fetch_failed=1 + fi + fi + if [[ $fetch_failed -eq 0 ]]; then + if [[ $only_head -eq 1 ]]; then + range=$head + msgs=$(git log -1 --format=%B "$head" 2>/dev/null); log_rc=$? + else + range="$base..$head" + msgs=$(git log --format=%B "$base..$head" 2>/dev/null); log_rc=$? + fi + if [[ $log_rc -eq 0 ]]; then + scan "commit messages" "$msgs" + else + emit "commit messages" "$range" "could not read the commit messages from git (could-not-look is never a pass)" + fi fi fi - scan "commit messages" "$msgs" fi fi if [[ $rc -eq 0 ]]; then - echo "term wall: clean — $(git ls-files 2>/dev/null | wc -l) tracked files, the change's messages, title, body and branch name carry no forbidden name" + echo "term wall: clean — $(git ls-files 2>/dev/null | wc -l) tracked files, their paths, and the change's messages, title, body and branch name carry no forbidden name" fi exit "$rc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b89eb6f..73bc8d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,25 @@ jobs: persist-credentials: false - name: term wall uses: ./.github/actions/term-wall - - name: term wall fires on a planted fault, stays quiet on a clean neighbour + - name: term wall self-test — fires on a planted fault, stays quiet on a clean neighbour, refuses an empty pattern + env: + TERM_WALL: ${{ vars.TERM_WALL }} + TERM_WALL_PLANT: ${{ vars.TERM_WALL_PLANT }} run: | set -euo pipefail wall="$GITHUB_WORKSPACE/.github/actions/term-wall/term-wall.sh" + [ -n "$TERM_WALL_PLANT" ] || { echo "::error::TERM_WALL_PLANT is unset — the self-test cannot plant a fault"; exit 1; } t=$(mktemp -d); cd "$t"; git init -q; git config user.email ci@ci.invalid; git config user.name ci printf 'clean\n' > c.txt; git add c.txt; git commit -qm clean env -u GITHUB_EVENT_PATH bash "$wall" - printf 'x \x53\x63\x69\x65\x6e\x74\x44\x42 y\n' > p.txt; git add p.txt - if env -u GITHUB_EVENT_PATH bash "$wall"; then echo "::error::the wall stayed quiet on a planted fault"; exit 1; fi - echo "the wall fires on a planted fault and stays quiet on a clean neighbour" + printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt + rc=0; out=$(env -u GITHUB_EVENT_PATH bash "$wall") || rc=$? + [ "$rc" -eq 1 ] || { echo "::error::the wall did not exit 1 on a planted fault (exit $rc)"; exit 1; } + case $out in *"$TERM_WALL_PLANT"*) echo "::error::the wall printed the raw plant"; exit 1;; esac + case $out in *"[forbidden name]"*) ;; *) echo "::error::the hit was not masked as [forbidden name]"; exit 1;; esac + rc=0; out=$(env -u GITHUB_EVENT_PATH TERM_WALL= bash "$wall" 2>/dev/null) || rc=$? + { [ "$rc" -eq 2 ] && [ -z "$out" ]; } || { echo "::error::an empty TERM_WALL did not refuse with exit 2 and empty stdout (exit $rc)"; exit 1; } + echo "the wall fires on a planted fault, stays quiet on a clean neighbour, and refuses an empty pattern" - name: JSON validity run: find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -r -n1 jq empty - name: YAML validity From a3142f3db8f8391aaa94755b911bce487986f36e Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:53:37 -0400 Subject: [PATCH 09/16] repo: pin how CI hands the term wall its pattern A composite action cannot read vars (GitHub refuses the template: 'Unrecognized named-value: vars', PR#8 run 33568333825), so the calling step passes env TERM_WALL from vars.TERM_WALL and the action refuses when it did not. Same for the self-test's planted fault. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY --- .github/actions/term-wall/CONTRACT.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/actions/term-wall/CONTRACT.md b/.github/actions/term-wall/CONTRACT.md index 9bf3f69..a81af96 100644 --- a/.github/actions/term-wall/CONTRACT.md +++ b/.github/actions/term-wall/CONTRACT.md @@ -15,8 +15,10 @@ encoded form. ## The pattern is configuration, never tree content - The pattern is an extended, case-insensitive regular expression read - from the environment variable `TERM_WALL`. In CI the action takes it - from the org-level Actions variable `vars.TERM_WALL`. Locally the + from the environment variable `TERM_WALL`. In CI the calling step + passes it: `env: TERM_WALL: ${{ vars.TERM_WALL }}` — a composite + action cannot read `vars` itself, so the action declares no default + and refuses when the step did not pass one. Locally the Python check reads `TERM_WALL`, falling back to the gitignored file `/ops/bin/term-wall.conf` (one line: the pattern). - No tracked file may contain the pattern, a piece of it, or any @@ -78,7 +80,7 @@ colon. The raw matched text never appears in any output. ## Self-test (the `.github` repo's own ci) -With `TERM_WALL_PLANT` as the planted fault: a planted file fires +With the calling step passing `env: TERM_WALL_PLANT: ${{ vars.TERM_WALL_PLANT }}` as the planted fault: a planted file fires (exit 1, masked hit), a clean neighbour stays quiet (exit 0), and an empty `TERM_WALL` refuses (exit 2). From 6175b67bd0df4710dd0bb8b79df413b452265c6c Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:02:36 -0400 Subject: [PATCH 10/16] repo: bring the term wall onto the pinned contract The pinned wire: one summary line on a clean run, one `: : ` per hit with the location never carrying a colon, four refusal classes. The pattern now arrives from the calling step (`env: TERM_WALL: ${{ vars.TERM_WALL }}`) because the runner refuses `vars` inside a composite action; the action refuses when the step passed nothing. ci.yml passes it. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T230235Z-apply-push-4be45d Patch-SHA256: c8606262ab7780275c529792f1882bd9c94478fff501ad995d318c286ef7eeb7 --- .github/actions/term-wall/action.yml | 10 +-- .github/actions/term-wall/term-wall.sh | 93 +++++++++++++++++--------- .github/workflows/ci.yml | 4 ++ 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/.github/actions/term-wall/action.yml b/.github/actions/term-wall/action.yml index 796452e..fbdd7b1 100644 --- a/.github/actions/term-wall/action.yml +++ b/.github/actions/term-wall/action.yml @@ -4,14 +4,14 @@ description: >- file paths, in the change's commit messages (read from git, never from an API: no token needed or declared), in the pull request title and body, and in the branch name. The pattern comes only from the - org-level Actions variable TERM_WALL; an empty pattern is a refusal, - never a pass. The wall never spells what it refuses and masks every - hit it prints. + TERM_WALL environment variable the calling step passes from the + org-level Actions variable — a composite action cannot read vars + itself, so this action declares no default; an unset or empty + pattern is a refusal, never a pass. The wall never spells what it + refuses and masks every hit it prints. runs: using: composite steps: - name: term wall shell: bash - env: - TERM_WALL: ${{ vars.TERM_WALL }} run: bash "$GITHUB_ACTION_PATH/term-wall.sh" diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh index b693efa..f7b123f 100755 --- a/.github/actions/term-wall/term-wall.sh +++ b/.github/actions/term-wall/term-wall.sh @@ -2,10 +2,12 @@ # term-wall.sh — names this organisation does not use, refused everywhere. # # The pattern is configuration, never tree content: it is read only from -# the TERM_WALL environment variable (in CI the composite action takes it -# from the org-level Actions variable vars.TERM_WALL). An unset or empty -# pattern is a refusal, exit 2 — the wall never passes vacuously. Every -# hit it prints is masked, so the log does not carry what the tree may not. +# the TERM_WALL environment variable (in CI the calling step passes it, +# `env: TERM_WALL: ${{ vars.TERM_WALL }}` — a composite action cannot +# read `vars` itself, so the action declares no default). An unset or +# empty pattern is a refusal, exit 2 — the wall never passes vacuously. +# Every hit it prints is masked, so the log does not carry what the tree +# may not. # # Surfaces, in order: # 1. tracked file content (case-insensitive, binaries skipped) @@ -19,10 +21,10 @@ # # Exit 0 clean, one summary line on stdout. Exit 1 on any hit, every hit # on stdout as ": : "; a surface that could not be read (fetch failed, -# payload unreadable) is itself a hit — could-not-look is never a pass. -# Exit 2 refusal: stdout empty, one line on stderr shaped -# "class: expected …; found …; needed …". +# [forbidden name]>" — the location never contains a colon; a surface +# that could not be read (fetch failed, payload unreadable) is itself a +# hit — could-not-look is never a pass. Exit 2 refusal: stdout empty, +# one line on stderr shaped "class: expected …; found …; needed …". # # Outside GitHub Actions (no GITHUB_EVENT_PATH) only surfaces 1 and 2 # run, against the current directory. @@ -41,7 +43,7 @@ printf '' | sed -E "s/($pat)/[forbidden name]/Ig" >/dev/null 2>&1 \ || refuse 'pattern: expected TERM_WALL usable in a sed substitution; found one sed rejects (an unescaped "/"?); needed a working pattern in the org variable (CI) or ops/bin/term-wall.conf (local)' git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ - || refuse 'worktree: expected to run inside a git work tree; found none; needed a checkout (CI) or a repository directory (local)' + || refuse 'git work tree: expected to run inside a git work tree; found none; needed a checkout (CI) or a repository directory (local)' rc=0 mask() { sed -E "s/($pat)/[forbidden name]/Ig"; } @@ -53,50 +55,72 @@ emit() { # emit — one hit. Called only rc=1 } -scan() { # scan — text as an argument, never a pipe: a hit - # must set rc in this shell, and a pipeline's stages run in subshells. - local surface=$1 text=$2 found line +scan() { # scan — text as an argument, + # never a pipe: a hit must set rc in this shell, and a pipeline's + # stages run in subshells. Each hit's location is + # "line ". + local surface=$1 prefix=$2 text=$3 found line [[ -n $text ]] || return 0 found=$(printf '%s\n' "$text" | grep -i -n -E -- "$pat" 2>/dev/null | mask || true) [[ -n $found ]] || return 0 while IFS= read -r line; do - emit "$surface" "${line%%:*}" "${line#*:}" + emit "$surface" "${prefix}line ${line%%:*}" "${line#*:}" done <<< "$found" } +scan_name() { # scan_name — for surfaces whose location + # is the (masked) value itself: a path, a branch name. + local surface=$1 value=$2 masked + [[ -n $value ]] || return 0 + printf '%s\n' "$value" | grep -i -E -- "$pat" >/dev/null 2>&1 || return 0 + masked=$(printf '%s\n' "$value" | mask) + emit "$surface" "$masked" "$masked" +} + # 1. tracked content content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -H -i -n -E -- "$pat" 2>/dev/null | mask || true) if [[ -n $content ]]; then while IFS= read -r line; do file=${line%%:*}; rest=${line#*:} - emit "content" "$file:${rest%%:*}" "${rest#*:}" + emit "content" "$file line ${rest%%:*}" "${rest#*:}" done <<< "$content" fi # 2. tracked paths -scan "path" "$(git ls-files 2>/dev/null)" +paths=$(git ls-files 2>/dev/null | grep -i -E -- "$pat" 2>/dev/null | mask || true) +if [[ -n $paths ]]; then + while IFS= read -r line; do + emit "path" "$line" "$line" + done <<< "$paths" +fi # 3-5. the change itself, when running under Actions if [[ -n ${GITHUB_EVENT_PATH:-} && -f ${GITHUB_EVENT_PATH:-} ]]; then event=${GITHUB_EVENT_NAME:-} + payload_ok=1 + jq empty "$GITHUB_EVENT_PATH" >/dev/null 2>&1 || payload_ok=0 + if [[ $payload_ok -eq 0 ]]; then + emit "event payload" "$GITHUB_EVENT_PATH" "could not read the event payload (could-not-look is never a pass)" + fi base="" head="" want_messages=0 case $event in pull_request|pull_request_target) - base=$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" - head=$(jq -r '.pull_request.head.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" - if title_body=$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH" 2>/dev/null); then - scan "pull request title/body" "$title_body" - else - emit "pull request title/body" "$GITHUB_EVENT_PATH" "could not read the event payload (could-not-look is never a pass)" + if [[ $payload_ok -eq 1 ]]; then + base=$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.pull_request.head.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" + scan "pull request title" "" "$(jq -r '.pull_request.title // ""' "$GITHUB_EVENT_PATH" 2>/dev/null)" + scan "pull request body" "" "$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" 2>/dev/null)" + want_messages=1 fi - scan "branch name" "${GITHUB_HEAD_REF:-}" - want_messages=1 + scan_name "branch name" "${GITHUB_HEAD_REF:-}" ;; push) - base=$(jq -r '.before // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" - head=$(jq -r '.after // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" - scan "branch name" "${GITHUB_REF_NAME:-}" - want_messages=1 + if [[ $payload_ok -eq 1 ]]; then + base=$(jq -r '.before // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.after // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" + want_messages=1 + fi + scan_name "branch name" "${GITHUB_REF_NAME:-}" ;; esac if [[ $want_messages -eq 1 ]]; then @@ -125,13 +149,20 @@ if [[ -n ${GITHUB_EVENT_PATH:-} && -f ${GITHUB_EVENT_PATH:-} ]]; then if [[ $fetch_failed -eq 0 ]]; then if [[ $only_head -eq 1 ]]; then range=$head - msgs=$(git log -1 --format=%B "$head" 2>/dev/null); log_rc=$? + shas=$(git rev-list -n 1 "$head" 2>/dev/null); list_rc=$? else range="$base..$head" - msgs=$(git log --format=%B "$base..$head" 2>/dev/null); log_rc=$? + shas=$(git rev-list "$base..$head" 2>/dev/null); list_rc=$? fi - if [[ $log_rc -eq 0 ]]; then - scan "commit messages" "$msgs" + if [[ $list_rc -eq 0 ]]; then + while IFS= read -r sha; do + [[ -n $sha ]] || continue + if msg=$(git log -1 --format=%B "$sha" 2>/dev/null); then + scan "commit messages" "${sha:0:12} " "$msg" + else + emit "commit messages" "${sha:0:12}" "could not read the commit message from git (could-not-look is never a pass)" + fi + done <<< "$shas" else emit "commit messages" "$range" "could not read the commit messages from git (could-not-look is never a pass)" fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73bc8d9..e66a322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ jobs: persist-credentials: false - name: term wall uses: ./.github/actions/term-wall + env: + TERM_WALL: ${{ vars.TERM_WALL }} + - name: term wall tests + run: python3 -m unittest discover -s .github/actions/term-wall/tests - name: term wall self-test — fires on a planted fault, stays quiet on a clean neighbour, refuses an empty pattern env: TERM_WALL: ${{ vars.TERM_WALL }} From 5502456825788f3c1da2a4dcbe52d44c97eec24b Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:46:31 -0400 Subject: [PATCH 11/16] repo: scan a pull request again when its text is edited `pull_request` without `types` fires on opened, synchronize and reopened only, so a title or body edited after the checks passed reached the merge unscanned. Name the types and add `edited`; the wall then runs against the current text. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T234631Z-apply-push-a1af92 Patch-SHA256: f9816cdf7b1a595a0a2c9e69366d5ae859daf5624cb7a5edb495fd8bf6a18491 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e66a322..ef9a50b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: ci on: pull_request: + types: [opened, synchronize, reopened, edited] push: branches: [dev, main] permissions: From 583634162ea8c092307ab5aa121ec13055657057 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:47:52 -0400 Subject: [PATCH 12/16] repo: pin the content surface to the object store A working-tree scan skipped what `grep -I` calls binary and followed symlinks to their targets, so a tracked blob carrying the name could land while the wall reported clean. The content surface is now every tracked blob, read from the object store, bytewise; a symlink is scanned as the blob it is, never followed; a blob that is not UTF-8 reports `[binary blob]` in place of the line. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T234752Z-apply-push-4f650a Patch-SHA256: 82bfe322636865f3130a554e957100917ee1f99402bea2137f9635d8cc838575 --- .github/actions/term-wall/CONTRACT.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/term-wall/CONTRACT.md b/.github/actions/term-wall/CONTRACT.md index a81af96..e54e6f1 100644 --- a/.github/actions/term-wall/CONTRACT.md +++ b/.github/actions/term-wall/CONTRACT.md @@ -95,3 +95,7 @@ no dependence on the caller's cwd, environment, or git identity (configure user.name/user.email in each temp repo). Every test asserts the exit code and the output shape. Push and pull-request events are simulated with an event JSON file and the `GITHUB_*` variables. + +## Content surface, pinned + +The content surface is every blob the scanned commit's tree tracks, read from the object store, never from the working tree. Every blob is scanned bytewise; nothing tracked is unscannable. A symlink entry is scanned as the blob it is, its target path text, and is never followed. A blob that is not valid UTF-8 reports each hit with the location ` line `, `n` counting newline-separated segments from 1, and the third field `[binary blob]` in place of the line. A blob the wall cannot read is a refusal of class `git work tree`: `git work tree: expected a readable blob at ; found ; needed the object`. From 891440e01ed31eec1a6ccf6199476dcef3117ad4 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:50:12 -0400 Subject: [PATCH 13/16] repo: tests for the content surface, from the contract Five subprocess tests, each in its own temporary repository: a blob that is not UTF-8 carrying the planted fault, a symlink whose target text carries it, a clean symlink to a clean file, a clean commit with a dirtied working tree, and a committed fault deleted from the working tree. Written against the pinned contract, before the implementation; red until it moves. Source: original Co-Authored-By: Codex Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T235011Z-apply-push-77cd09 Patch-SHA256: 7b9e0252dd5761078e3b1a1f88134326ef7d53ec10efdb4b55d5b9538c9d5e06 --- .../actions/term-wall/tests/test_term_wall.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/.github/actions/term-wall/tests/test_term_wall.py b/.github/actions/term-wall/tests/test_term_wall.py index b3448b7..1976e89 100644 --- a/.github/actions/term-wall/tests/test_term_wall.py +++ b/.github/actions/term-wall/tests/test_term_wall.py @@ -124,6 +124,62 @@ def test_binary_tracked_content_is_skipped(self): self.commit("add binary fixture") self.assert_clean(self.run_wall()) + def test_non_utf8_committed_blob_hit_uses_binary_placeholder(self): + (self.repo / "fixture.bin").write_bytes( + b"ordinary first line\ncontains " + PLANT.encode("ascii") + b" \xff\n" + ) + self.commit("add non-UTF-8 fixture") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: fixture.bin line 2: [binary blob]\n", + ) + + def test_dangling_symlink_target_text_is_scanned_as_content(self): + os.symlink(f"absent-{PLANT}-target", self.repo / "dangling-link") + self.commit("add dangling symlink") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: dangling-link line 1: absent-[forbidden name]-target\n", + ) + + def test_clean_symlink_to_committed_clean_file_is_clean(self): + os.symlink("clean.txt", self.repo / "clean-link") + self.commit("add clean symlink") + + self.assert_clean(self.run_wall()) + + def test_uncommitted_working_tree_content_is_not_scanned(self): + self.write("tracked.txt", "committed clean content\n") + self.commit("add clean tracked file") + self.write("tracked.txt", f"working tree contains {PLANT}\n") + + self.assert_clean(self.run_wall()) + + def test_committed_blob_deleted_only_from_working_tree_is_scanned(self): + self.write("deleted.txt", f"committed content contains {PLANT}\n") + self.commit("add planted tracked file") + (self.repo / "deleted.txt").unlink() + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: deleted.txt line 1: committed content contains " + "[forbidden name]\n", + ) + def test_tracked_path_hit_is_masked(self): self.write(f"notes-{PLANT}.txt", "ordinary text\n") self.commit("add fixture") From 5c1a01ecda312782483e739a28a27b52ee93b0b5 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:57:52 -0400 Subject: [PATCH 14/16] repo: scan the content surface from the object store Enumerate the scanned commit's tree with `git ls-tree -r -z`, read each blob with `git cat-file`, scan bytewise; a symlink is scanned as the blob it is and never followed; a blob that is not UTF-8 reports `[binary blob]` in place of the line; a blob that cannot be read is a `git work tree` refusal. Implementation moves to the pinned contract; the tests stand. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T235752Z-apply-push-437ac9 Patch-SHA256: 47bcffbb08b404c5732c0e9ef2ed74a2344887559f096ab5c6dfe6f74048ccd2 --- .github/actions/term-wall/term-wall.sh | 49 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh index f7b123f..64b8bbe 100755 --- a/.github/actions/term-wall/term-wall.sh +++ b/.github/actions/term-wall/term-wall.sh @@ -10,7 +10,12 @@ # may not. # # Surfaces, in order: -# 1. tracked file content (case-insensitive, binaries skipped) +# 1. tracked file content — every blob the scanned commit's tree +# tracks, read from the object store, never from the working tree, +# and scanned bytewise (case-insensitive): nothing tracked is +# unscannable. A symlink entry is scanned as the blob it is — its +# target path text — and never followed. A blob that is not valid +# UTF-8 reports each hit with "[binary blob]" in place of the line. # 2. tracked file paths # 3. the commit messages of the change — pull_request: base..head; # push: before..head, or only the head commit when before is all @@ -77,13 +82,41 @@ scan_name() { # scan_name — for surfaces whose location emit "$surface" "$masked" "$masked" } -# 1. tracked content -content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -H -i -n -E -- "$pat" 2>/dev/null | mask || true) -if [[ -n $content ]]; then - while IFS= read -r line; do - file=${line%%:*}; rest=${line#*:} - emit "content" "$file line ${rest%%:*}" "${rest#*:}" - done <<< "$content" +# 1. tracked content — every blob of the scanned commit's tree, from the +# object store, bytewise (grep -a, never -I). A symlink entry (mode +# 120000) is a blob holding its target path and is never followed; a +# gitlink (type commit) is not a blob and has no content here. A blob +# the wall cannot read is a refusal — could-not-look is never a pass. +if git rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + git ls-tree -r HEAD >/dev/null 2>&1 \ + || refuse 'git work tree: expected a readable tree at HEAD; found git ls-tree cannot read it; needed the object' + while IFS= read -r -d '' entry; do + meta=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r _mode type oid <<< "$meta" + [[ $type == blob ]] || continue + if ! err=$(git cat-file -e "$oid" 2>&1); then + err=$(printf '%s' "${err:-git cat-file cannot read $oid}" | tr '\n' ' ') + refuse "$(printf 'git work tree: expected a readable blob at %s; found %s; needed the object' "$path" "$err" | mask)" + fi + if git cat-file blob "$oid" 2>/dev/null | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then + # NUL is valid UTF-8 (U+0000) but a shell variable cannot hold + # it — drop it before masking, never after. + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | tr -d '\000' | mask || true) + [[ -n $found ]] || continue + while IFS= read -r line; do + emit "content" "$path line ${line%%:*}" "${line#*:}" + done <<< "$found" + else + # Not valid UTF-8: never echo its bytes — only line numbers + # (newline-separated segments, from 1) and "[binary blob]". + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | cut -d: -f1 || true) + [[ -n $found ]] || continue + while IFS= read -r n; do + emit "content" "$path line $n" "[binary blob]" + done <<< "$found" + fi + done < <(git ls-tree -r -z HEAD 2>/dev/null) fi # 2. tracked paths From 2fc23a29bd186462ddf8e8b1ef2c59529e229896 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 19:59:28 -0400 Subject: [PATCH 15/16] repo: retire the test that asserted a skipped binary blob The pinned content surface makes every tracked blob scannable, so the pre-existing test asserting that a binary blob passed as clean contradicts the contract. The binary-blob test written against the amendment covers the case. Source: original Co-Authored-By: Codex Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260901T235927Z-apply-push-91f0e5 Patch-SHA256: 5f57e606b263e185759beacf0f8ec59baa69a00b7b4c23c80e85b3c8e6aa3a9f --- .github/actions/term-wall/tests/test_term_wall.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/actions/term-wall/tests/test_term_wall.py b/.github/actions/term-wall/tests/test_term_wall.py index 1976e89..bdd2e43 100644 --- a/.github/actions/term-wall/tests/test_term_wall.py +++ b/.github/actions/term-wall/tests/test_term_wall.py @@ -119,11 +119,6 @@ def test_tracked_content_hit_is_masked(self): self.commit("add fixture") self.assert_hit(self.run_wall(), "content") - def test_binary_tracked_content_is_skipped(self): - (self.repo / "fixture.bin").write_bytes(b"\x00" + PLANT.encode("ascii") + b"\xff") - self.commit("add binary fixture") - self.assert_clean(self.run_wall()) - def test_non_utf8_committed_blob_hit_uses_binary_placeholder(self): (self.repo / "fixture.bin").write_bytes( b"ordinary first line\ncontains " + PLANT.encode("ascii") + b" \xff\n" From a1e76d027223d943a0c8152971a965926d17c016 Mon Sep 17 00:00:00 2001 From: Apply Push Bridge Date: Tue, 1 Sep 2026 20:00:46 -0400 Subject: [PATCH 16/16] repo: commit the planted fault in the self-test The content surface is the scanned commit's tree, so a planted file that is only staged is not on it. The self-test now commits the plant before expecting the wall to fire. Source: original Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Jj94rkp3tfHAxUkTCthgY Apply-Push-Job: 20260902T000046Z-apply-push-aa7d57 Patch-SHA256: fa75a5987b8760303465c1ab5e6ec4a2aa9e52100f37a9e9a34a7537baa00fd6 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e66a322..1f688ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: t=$(mktemp -d); cd "$t"; git init -q; git config user.email ci@ci.invalid; git config user.name ci printf 'clean\n' > c.txt; git add c.txt; git commit -qm clean env -u GITHUB_EVENT_PATH bash "$wall" - printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt + printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt; git commit -qm plant rc=0; out=$(env -u GITHUB_EVENT_PATH bash "$wall") || rc=$? [ "$rc" -eq 1 ] || { echo "::error::the wall did not exit 1 on a planted fault (exit $rc)"; exit 1; } case $out in *"$TERM_WALL_PLANT"*) echo "::error::the wall printed the raw plant"; exit 1;; esac