From b121f0c496934098545fb8a9757ce7207b828474 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:18:46 +0300 Subject: [PATCH 01/30] feat: derive maintained branches from support snapshot in plugin --- hooks/parse_legacy_file.lua | 2 +- lib/policy.lua | 10 ++++++++++ lib/releases.lua | 13 +++++++++++-- scripts/generate-policy-lua | 14 ++++++++++++++ scripts/test.sh | 19 ++++++++++++++++++- test/mock_server.py | 30 ++++++++++++++++++++++++++++-- 6 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 lib/policy.lua create mode 100755 scripts/generate-policy-lua diff --git a/hooks/parse_legacy_file.lua b/hooks/parse_legacy_file.lua index 8563f3d..9d0fe4e 100644 --- a/hooks/parse_legacy_file.lua +++ b/hooks/parse_legacy_file.lua @@ -6,6 +6,6 @@ function PLUGIN:ParseLegacyFile(ctx) error("failed to read " .. ctx.filepath) end - local version = content:match("(8%.[2-5][^%s]*)") + local version = content:match("(%d+%.%d+[^%s]*)") return { version = version } end diff --git a/lib/policy.lua b/lib/policy.lua new file mode 100644 index 0000000..09180ca --- /dev/null +++ b/lib/policy.lua @@ -0,0 +1,10 @@ +-- Generated by scripts/generate-policy-lua from support-snapshot.json. +-- Do not edit by hand; regenerate when the snapshot changes. +return { + maintained = { + "8.2", + "8.3", + "8.4", + "8.5", + }, +} diff --git a/lib/releases.lua b/lib/releases.lua index a5f3ba5..4da2605 100644 --- a/lib/releases.lua +++ b/lib/releases.lua @@ -1,5 +1,6 @@ local http = require("http") local json = require("json") +local policy = require("policy") local M = {} @@ -47,8 +48,16 @@ end function M.is_supported_version(version) - return version:match("^8%.[2-5]%.%d+$") ~= nil - or version:match("^8%.[2-5]%.%d+%-[1-9]%d*$") ~= nil + for _, branch in ipairs(policy.maintained) do + local prefix = "^" .. branch:gsub("%.", "%%.") .. "%.%d+" + if version:match(prefix .. "$") ~= nil + or version:match(prefix .. "%-[1-9]%d*$") ~= nil + then + return true + end + end + + return false end diff --git a/scripts/generate-policy-lua b/scripts/generate-policy-lua new file mode 100755 index 0000000..ef8f919 --- /dev/null +++ b/scripts/generate-policy-lua @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Regenerates lib/policy.lua from support-snapshot.json so the Lua plugin +# lists exactly the maintained branches without hardcoding them. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +{ + echo "-- Generated by scripts/generate-policy-lua from support-snapshot.json." + echo "-- Do not edit by hand; regenerate when the snapshot changes." + echo "return {" + echo " maintained = {" + jq -r '.maintainedBranches[] | " \"\(.)\","' support-snapshot.json + echo " }," + echo "}" +} > lib/policy.lua diff --git a/scripts/test.sh b/scripts/test.sh index d2a62af..c1fa180 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,6 +8,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" "$SCRIPT_DIR/check-public-language.sh" "$SCRIPT_DIR/validate-codex-action-inputs" "$SCRIPT_DIR/validate-structured-output-schemas" +"$SCRIPT_DIR/generate-policy-lua" +git -C "$PROJECT_ROOT" diff --exit-code lib/policy.lua if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then echo "Plugin installation tests require macOS arm64." >&2 @@ -44,9 +46,11 @@ ARCHIVE_NAME="php-8.4.99-cli-macos-aarch64.tar.gz" EOL_ARCHIVE_NAME="php-8.1.99-cli-macos-aarch64.tar.gz" COPYFILE_DISABLE=1 tar -czf "$TEMP_DIR/assets/$ARCHIVE_NAME" -C "$TEMP_DIR/assets/package" . cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$EOL_ARCHIVE_NAME" +FUTURE_ARCHIVE_NAME="php-9.0.1-cli-macos-aarch64.tar.gz" +cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$FUTURE_ARCHIVE_NAME" ( cd "$TEMP_DIR/assets" - shasum -a 256 "$ARCHIVE_NAME" "$EOL_ARCHIVE_NAME" > SHA256SUMS + shasum -a 256 "$ARCHIVE_NAME" "$EOL_ARCHIVE_NAME" "$FUTURE_ARCHIVE_NAME" > SHA256SUMS ) PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" @@ -75,6 +79,19 @@ if grep -Fx "8.1.99" <<< "$AVAILABLE_VERSIONS"; then echo "EOL PHP release was unexpectedly listed." >&2 exit 1 fi + +# A future branch appears in listings the moment the snapshot maintains it. +if grep -Fx "9.0.1" <<< "$AVAILABLE_VERSIONS"; then + echo "Unmaintained future branch was unexpectedly listed." >&2 + exit 1 +fi +ORIGINAL_POLICY="$(cat "$PROJECT_ROOT/lib/policy.lua")" +restore_policy() { printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua"; } +printf 'return {\n maintained = { "8.2", "8.3", "8.4", "8.5", "9.0" },\n}\n' > "$PROJECT_ROOT/lib/policy.lua" +FUTURE_VERSIONS="$(mise ls-remote php)" +restore_policy +grep -Fx "9.0.1" <<< "$FUTURE_VERSIONS" + mise install php@8.4 test -x "$MISE_DATA_DIR/installs/php/8.4.99/bin/php" mise exec php@8.4 -- php -v | grep -F "PHP 8.4.99" diff --git a/test/mock_server.py b/test/mock_server.py index 9cde3b3..fb3cfbb 100755 --- a/test/mock_server.py +++ b/test/mock_server.py @@ -11,8 +11,10 @@ ASSET_DIR = Path(sys.argv[2]).resolve() VERSION = "8.4.99" EOL_VERSION = "8.1.99" +FUTURE_VERSION = "9.0.1" ARCHIVE_NAME = f"php-{VERSION}-cli-macos-aarch64.tar.gz" EOL_ARCHIVE_NAME = f"php-{EOL_VERSION}-cli-macos-aarch64.tar.gz" +FUTURE_ARCHIVE_NAME = f"php-{FUTURE_VERSION}-cli-macos-aarch64.tar.gz" def release_payload() -> dict: @@ -53,6 +55,25 @@ def eol_release_payload() -> dict: } +def future_release_payload() -> dict: + base_url = f"http://127.0.0.1:{PORT}/assets" + return { + "tag_name": FUTURE_VERSION, + "draft": False, + "prerelease": False, + "assets": [ + { + "name": FUTURE_ARCHIVE_NAME, + "browser_download_url": f"{base_url}/{FUTURE_ARCHIVE_NAME}", + }, + { + "name": "SHA256SUMS", + "browser_download_url": f"{base_url}/SHA256SUMS", + }, + ], + } + + class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: path = urlparse(self.path).path @@ -62,7 +83,9 @@ def do_GET(self) -> None: return if path == "/repos/bigpixelrocket/php-bin/releases": - self.send_json([release_payload(), eol_release_payload()]) + self.send_json( + [release_payload(), eol_release_payload(), future_release_payload()] + ) return if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{VERSION}": @@ -71,11 +94,14 @@ def do_GET(self) -> None: if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{EOL_VERSION}": self.send_json(eol_release_payload()) return + if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{FUTURE_VERSION}": + self.send_json(future_release_payload()) + return asset_prefix = "/assets/" if path.startswith(asset_prefix): name = path[len(asset_prefix) :] - if name not in {ARCHIVE_NAME, EOL_ARCHIVE_NAME, "SHA256SUMS"}: + if name not in {ARCHIVE_NAME, EOL_ARCHIVE_NAME, FUTURE_ARCHIVE_NAME, "SHA256SUMS"}: self.send_error(404) return From ec66bfdae5fb0deb6b037e5a3c81d6c7ba97e8c7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:26:02 +0300 Subject: [PATCH 02/30] fix: restore policy.lua via cleanup trap in plugin test --- scripts/test.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/test.sh b/scripts/test.sh index c1fa180..e16f065 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -23,7 +23,11 @@ fi TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mise-php-test.XXXXXX")" SERVER_PID="" +ORIGINAL_POLICY="" cleanup() { + if [[ -n "$ORIGINAL_POLICY" ]]; then + printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua" + fi if [[ -n "$SERVER_PID" ]]; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true @@ -85,11 +89,11 @@ if grep -Fx "9.0.1" <<< "$AVAILABLE_VERSIONS"; then echo "Unmaintained future branch was unexpectedly listed." >&2 exit 1 fi +# cleanup restores lib/policy.lua, so a failure mid-swap cannot leave it mutated. ORIGINAL_POLICY="$(cat "$PROJECT_ROOT/lib/policy.lua")" -restore_policy() { printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua"; } printf 'return {\n maintained = { "8.2", "8.3", "8.4", "8.5", "9.0" },\n}\n' > "$PROJECT_ROOT/lib/policy.lua" FUTURE_VERSIONS="$(mise ls-remote php)" -restore_policy +printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua" grep -Fx "9.0.1" <<< "$FUTURE_VERSIONS" mise install php@8.4 From 06fc5250980d10814247d28360ac5e686e619836 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:32:13 +0300 Subject: [PATCH 03/30] feat: reject snapshot diffs with stale policy.lua --- .github/codex/autorelease/implementation.md | 6 +- .github/codex/autorelease/investigation.md | 6 +- autorelease/admission.py | 14 ++++ test/test_autorelease.py | 87 ++++++++++++++++++++- 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/.github/codex/autorelease/implementation.md b/.github/codex/autorelease/implementation.md index e3b8310..b261eef 100644 --- a/.github/codex/autorelease/implementation.md +++ b/.github/codex/autorelease/implementation.md @@ -5,6 +5,8 @@ inside only admitted paths, and leave a diff ready for deterministic sealing and clean validation. Use no web or shell network. Run and record all advisory checks. Do not change -protected or unadmitted paths. Return GO only when all criteria pass, the local -support behavior matches the accepted php-bin policy, and unresolved is empty. +protected or unadmitted paths. When the edit changes `support-snapshot.json`, +run `scripts/generate-policy-lua` and include the regenerated `lib/policy.lua` +in the same diff. Return GO only when all criteria pass, the local support +behavior matches the accepted php-bin policy, and unresolved is empty. Do not commit, push, merge, tag, publish, or record readiness yourself. diff --git a/.github/codex/autorelease/investigation.md b/.github/codex/autorelease/investigation.md index 9c0db47..fda6b07 100644 --- a/.github/codex/autorelease/investigation.md +++ b/.github/codex/autorelease/investigation.md @@ -5,8 +5,10 @@ with the exact local support snapshot, then produce one evidence-bound plan without modifying the repository. Identify whether local parsing, filtering, fixtures, documentation, temporary -artifact installation, or readiness state must change. Cite exact public policy -commit and digests. Do not independently fetch or classify upstream PHP data. +artifact installation, or readiness state must change. A `support-snapshot.json` +edit also regenerates `lib/policy.lua`, so admit both paths in the same plan. +Cite exact public policy commit and digests. Do not independently fetch or +classify upstream PHP data. Return GO only when every criterion passes and unresolved is empty. Treat `requiredChecks` as downstream exact-head gates, not investigation-phase diff --git a/autorelease/admission.py b/autorelease/admission.py index cd40144..bb172ea 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -364,6 +364,20 @@ def seal( "policyInvariantsDigest", "maintainedBranches", "generated", } or snapshot.get("schemaVersion") != 1 or snapshot.get("generated") is not True: raise AdmissionError("support snapshot has unknown, missing, or invalid fields") + # The plugin filters branches through the generated lib/policy.lua, so a + # snapshot edit without a regenerated file would ship a stale filter. + expected_policy_lines = [ + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.", + "-- Do not edit by hand; regenerate when the snapshot changes.", + "return {", + " maintained = {", + *[f' "{branch}",' for branch in snapshot.get("maintainedBranches", [])], + " },", + "}", + ] + policy_lua = repo / "lib" / "policy.lua" + if policy_lua.read_text().splitlines() != expected_policy_lines: + raise AdmissionError("support snapshot changed without regenerating lib/policy.lua") files.append({"path": path, "digest": digest_bytes(body), "mode": oct(mode)}) output.mkdir(parents=True, exist_ok=True) patch = output / "sealed.patch" diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 8be362d..304819d 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -6,7 +6,7 @@ from unittest import mock from autorelease import consumer -from autorelease.admission import AdmissionError, admit, digest_file, protected, verify_merge +from autorelease.admission import AdmissionError, admit, digest_file, protected, seal, verify_merge from autorelease.consumer import ( CaptureAbsent, ConsumerError, @@ -252,6 +252,91 @@ def test_admission_binds_complete_policy_capture_and_contract(self): "sha256:" + "f" * 64, invariants_digest, preconditions["misePhpHead"], ) + def test_snapshot_diff_requires_matching_policy_lua(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + repo = root / "repo" + (repo / "lib").mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@invalid"], cwd=repo, check=True) + accepted = ["8.3", "8.4", "8.5", "8.6"] + superseded = ["8.2", "8.3", "8.4", "8.5"] + policy = root / "support-policy.json" + policy.write_text(json.dumps({"maintainedBranches": accepted}) + "\n") + preconditions = { + "misePhpHead": "d" * 40, + "phpBinPolicyCommit": "a" * 40, + "supportPolicyDigest": digest_file(policy), + "policyInvariantsDigest": "sha256:" + "c" * 64, + "phpBinOperatorCommit": "e" * 40, + "operatorState": "enabled", + } + snapshot = repo / "support-snapshot.json" + policy_lua = repo / "lib" / "policy.lua" + generated = ( + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.\n" + "-- Do not edit by hand; regenerate when the snapshot changes.\n" + "return {\n" + " maintained = {\n" + "%s" + " },\n" + "}\n" + ) + snapshot.write_text(json.dumps({ + "schemaVersion": 1, + "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], + "policyDigest": preconditions["supportPolicyDigest"], + "policyInvariantsDigest": preconditions["policyInvariantsDigest"], + "maintainedBranches": superseded, + "generated": True, + }) + "\n") + policy_lua.write_text(generated % "".join(f' "{branch}",\n' for branch in superseded)) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + digests = { + "shared": "sha256:" + "1" * 64, + "phaseTemplate": "sha256:" + "2" * 64, + "eventContract": "sha256:" + "3" * 64, + } + contract = { + "actionKey": "new_branch:8.6", + "preconditions": preconditions, + "completionCriteria": [{"id": "done"}], + } + plan = { + "actionKey": "new_branch:8.6", + "agentContract": {"instructionDigests": digests}, + "preconditions": preconditions, + "allowedPaths": {"mise-php": ["support-snapshot.json", "lib/policy.lua"]}, + } + result = { + "instructionDigests": digests, + "phaseStatus": "complete", + "criteria": [{"id": "done", "status": "passed", "evidence": ["preconditions.misePhpHead"]}], + "unresolved": [], + "goNoGo": "go", + } + snapshot.write_text(json.dumps({ + "schemaVersion": 1, + "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], + "policyDigest": preconditions["supportPolicyDigest"], + "policyInvariantsDigest": preconditions["policyInvariantsDigest"], + "maintainedBranches": accepted, + "generated": True, + }) + "\n") + with self.assertRaises(AdmissionError) as ctx: + seal(repo, base, plan, result, contract, policy, root / "sealed") + self.assertIn("policy.lua", str(ctx.exception)) + policy_lua.write_text(generated % "".join(f' "{branch}",\n' for branch in accepted)) + manifest = seal(repo, base, plan, result, contract, policy, root / "sealed") + self.assertEqual( + ["lib/policy.lua", "support-snapshot.json"], [item["path"] for item in manifest["files"]] + ) + def test_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1] ci = (root / ".github/workflows/ci.yml").read_text() From 8e309077ea7d8a91226d483641d19d118f8f654e Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:35:44 +0300 Subject: [PATCH 04/30] fix: require the generated policy.lua path in admitted plans --- autorelease/admission.py | 6 +- test/test_autorelease.py | 183 +++++++++++++++++++++------------------ 2 files changed, 105 insertions(+), 84 deletions(-) diff --git a/autorelease/admission.py b/autorelease/admission.py index bb172ea..6defbba 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -254,6 +254,10 @@ def admit( flattened.append(pattern) if not any(fnmatch.fnmatch("support-snapshot.json", pattern) for pattern in flattened): raise AdmissionError("policy synchronization does not admit the generated support snapshot") + # Sealing rejects a snapshot edit whose lib/policy.lua was not regenerated, so a + # plan that cannot carry the regenerated file is unsatisfiable rather than risky. + if not any(fnmatch.fnmatch("lib/policy.lua", pattern) for pattern in flattened): + raise AdmissionError("policy synchronization does not admit the generated lib/policy.lua") operations = plan.get("agentOperations") if not isinstance(operations, list) or not all(isinstance(item, str) for item in operations): raise AdmissionError("agent operations must be an array of strings") @@ -376,7 +380,7 @@ def seal( "}", ] policy_lua = repo / "lib" / "policy.lua" - if policy_lua.read_text().splitlines() != expected_policy_lines: + if not policy_lua.is_file() or policy_lua.read_text().splitlines() != expected_policy_lines: raise AdmissionError("support snapshot changed without regenerating lib/policy.lua") files.append({"path": path, "digest": digest_bytes(body), "mode": oct(mode)}) output.mkdir(parents=True, exist_ok=True) diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 304819d..23a0374 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -165,92 +165,109 @@ def test_merge_gate_binds_single_commit_diff_and_preconditions(self): with self.assertRaises(AdmissionError): verify_merge(root, mutated, manifest, {"Plugin contract": "success"}, state, state) + # Returns an admissible plan plus the remaining admit() arguments by keyword, so + # a test can vary one part of the plan without rebuilding the policy capture. + def admission_fixture(self, root): + shared = root / "shared.md" + phase = root / "phase.md" + event = root / "event.json" + shared.write_text("shared\n") + phase.write_text("phase\n") + commit_sha = "a" * 40 + policy_digest = "sha256:" + "b" * 64 + invariants_digest = "sha256:" + "c" * 64 + preconditions = { + "misePhpHead": "d" * 40, + "phpBinPolicyCommit": commit_sha, + "supportPolicyDigest": policy_digest, + "policyInvariantsDigest": invariants_digest, + "phpBinOperatorCommit": "e" * 40, + "operatorState": "enabled", + } + contract = { + "contractVersion": 1, + "actionKey": "new_branch:8.6", + "preconditions": preconditions, + "completionCriteria": [{"id": "done"}], + } + event.write_text(json.dumps(contract) + "\n") + captures = [ + ("php_bin_policy_selector", [{"sha": commit_sha}], "/0/sha"), + ("php_bin_state", {"sha": commit_sha}, "/sha"), + ("support_policy", {"maintainedBranches": ["8.6"]}, "/maintainedBranches"), + ("policy_invariants", {"target": {"os": "macOS"}}, "/target"), + ] + manifest_records = [] + evidence = [] + for capture_id, body, pointer in captures: + path = root / f"{capture_id}.json" + path.write_text(json.dumps(body) + "\n") + body_digest = digest(path.read_bytes()) + if capture_id == "support_policy": + policy_digest = body_digest + preconditions["supportPolicyDigest"] = body_digest + elif capture_id == "policy_invariants": + invariants_digest = body_digest + preconditions["policyInvariantsDigest"] = body_digest + manifest_records.append({"captureId": capture_id, "bodyPath": path.name, "digest": body_digest}) + evidence.append({"captureId": capture_id, "digest": body_digest, "locator": {"kind": "json_pointer", "value": pointer}}) + event.write_text(json.dumps(contract) + "\n") + manifest = root / "capture.json" + manifest.write_text(json.dumps({"schemaVersion": 1, "captures": manifest_records}) + "\n") + digests = { + "shared": digest_file(shared), + "phaseTemplate": digest_file(phase), + "eventContract": digest_file(event), + } + plan = { + "schemaVersion": 1, + "actionKey": "new_branch:8.6", + "action": "new_branch", + "agentContract": {"instructionDigests": digests}, + "completionAssessment": { + "instructionDigests": digests, + "phaseStatus": "complete", + "criteria": [{"id": "done", "status": "passed", "evidence": ["evidence[0]"]}], + "unresolved": [], + "goNoGo": "go", + }, + "preconditions": preconditions, + "evidence": evidence, + "repositories": ["mise-php"], + "editsRequired": True, + "allowedPaths": {"mise-php": ["support-snapshot.json", "lib/policy.lua"]}, + "requiredChecks": ["Plugin contract"], + "risk": "lifecycle", + "agentOperations": [], + "budgets": {"maxModelCalls": 1, "maxRetries": 1, "timeoutMinutes": 30}, + } + return plan, { + "contract": contract, + "shared": shared, + "phase": phase, + "event": event, + "capture_manifest": manifest, + "policy_digest": policy_digest, + "invariants_digest": invariants_digest, + "mise_head": preconditions["misePhpHead"], + } + def test_admission_binds_complete_policy_capture_and_contract(self): with tempfile.TemporaryDirectory() as temporary: - root = pathlib.Path(temporary) - shared = root / "shared.md" - phase = root / "phase.md" - event = root / "event.json" - shared.write_text("shared\n") - phase.write_text("phase\n") - commit_sha = "a" * 40 - policy_digest = "sha256:" + "b" * 64 - invariants_digest = "sha256:" + "c" * 64 - preconditions = { - "misePhpHead": "d" * 40, - "phpBinPolicyCommit": commit_sha, - "supportPolicyDigest": policy_digest, - "policyInvariantsDigest": invariants_digest, - "phpBinOperatorCommit": "e" * 40, - "operatorState": "enabled", - } - contract = { - "contractVersion": 1, - "actionKey": "new_branch:8.6", - "preconditions": preconditions, - "completionCriteria": [{"id": "done"}], - } - event.write_text(json.dumps(contract) + "\n") - captures = [ - ("php_bin_policy_selector", [{"sha": commit_sha}], "/0/sha"), - ("php_bin_state", {"sha": commit_sha}, "/sha"), - ("support_policy", {"maintainedBranches": ["8.6"]}, "/maintainedBranches"), - ("policy_invariants", {"target": {"os": "macOS"}}, "/target"), - ] - manifest_records = [] - evidence = [] - for capture_id, body, pointer in captures: - path = root / f"{capture_id}.json" - path.write_text(json.dumps(body) + "\n") - body_digest = digest(path.read_bytes()) - if capture_id == "support_policy": - policy_digest = body_digest - preconditions["supportPolicyDigest"] = body_digest - elif capture_id == "policy_invariants": - invariants_digest = body_digest - preconditions["policyInvariantsDigest"] = body_digest - manifest_records.append({"captureId": capture_id, "bodyPath": path.name, "digest": body_digest}) - evidence.append({"captureId": capture_id, "digest": body_digest, "locator": {"kind": "json_pointer", "value": pointer}}) - event.write_text(json.dumps(contract) + "\n") - manifest = root / "capture.json" - manifest.write_text(json.dumps({"schemaVersion": 1, "captures": manifest_records}) + "\n") - digests = { - "shared": digest_file(shared), - "phaseTemplate": digest_file(phase), - "eventContract": digest_file(event), - } - plan = { - "schemaVersion": 1, - "actionKey": "new_branch:8.6", - "action": "new_branch", - "agentContract": {"instructionDigests": digests}, - "completionAssessment": { - "instructionDigests": digests, - "phaseStatus": "complete", - "criteria": [{"id": "done", "status": "passed", "evidence": ["evidence[0]"]}], - "unresolved": [], - "goNoGo": "go", - }, - "preconditions": preconditions, - "evidence": evidence, - "repositories": ["mise-php"], - "editsRequired": True, - "allowedPaths": {"mise-php": ["support-snapshot.json"]}, - "requiredChecks": ["Plugin contract"], - "risk": "lifecycle", - "agentOperations": [], - "budgets": {"maxModelCalls": 1, "maxRetries": 1, "timeoutMinutes": 30}, - } - result = admit( - plan, contract, shared, phase, event, manifest, - policy_digest, invariants_digest, preconditions["misePhpHead"], - ) - self.assertTrue(result["admitted"]) + plan, arguments = self.admission_fixture(pathlib.Path(temporary)) + self.assertTrue(admit(plan, **arguments)["admitted"]) with self.assertRaises(AdmissionError): - admit( - plan, contract, shared, phase, event, manifest, - "sha256:" + "f" * 64, invariants_digest, preconditions["misePhpHead"], - ) + admit(plan, **{**arguments, "policy_digest": "sha256:" + "f" * 64}) + + def test_admission_requires_the_generated_policy_lua_path(self): + with tempfile.TemporaryDirectory() as temporary: + plan, arguments = self.admission_fixture(pathlib.Path(temporary)) + plan["allowedPaths"] = {"mise-php": ["support-snapshot.json"]} + with self.assertRaises(AdmissionError) as ctx: + admit(plan, **arguments) + self.assertIn("lib/policy.lua", str(ctx.exception)) + plan["allowedPaths"] = {"mise-php": ["support-snapshot.json", "lib/policy.lua"]} + self.assertTrue(admit(plan, **arguments)["admitted"]) def test_snapshot_diff_requires_matching_policy_lua(self): with tempfile.TemporaryDirectory() as temporary: From eb188df5ac1512ca9312fdb729316994a3d50e06 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:43:40 +0300 Subject: [PATCH 05/30] fix: check snapshot and policy.lua agreement for either diff path --- autorelease/admission.py | 30 ++++---- test/test_autorelease.py | 156 +++++++++++++++++++++------------------ 2 files changed, 102 insertions(+), 84 deletions(-) diff --git a/autorelease/admission.py b/autorelease/admission.py index 6defbba..677d1be 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -368,21 +368,23 @@ def seal( "policyInvariantsDigest", "maintainedBranches", "generated", } or snapshot.get("schemaVersion") != 1 or snapshot.get("generated") is not True: raise AdmissionError("support snapshot has unknown, missing, or invalid fields") - # The plugin filters branches through the generated lib/policy.lua, so a - # snapshot edit without a regenerated file would ship a stale filter. - expected_policy_lines = [ - "-- Generated by scripts/generate-policy-lua from support-snapshot.json.", - "-- Do not edit by hand; regenerate when the snapshot changes.", - "return {", - " maintained = {", - *[f' "{branch}",' for branch in snapshot.get("maintainedBranches", [])], - " },", - "}", - ] - policy_lua = repo / "lib" / "policy.lua" - if not policy_lua.is_file() or policy_lua.read_text().splitlines() != expected_policy_lines: - raise AdmissionError("support snapshot changed without regenerating lib/policy.lua") files.append({"path": path, "digest": digest_bytes(body), "mode": oct(mode)}) + # The plugin filters branches through the generated lib/policy.lua, so either file + # changing alone would ship a filter that disagrees with the accepted snapshot. + if "support-snapshot.json" in paths or "lib/policy.lua" in paths: + maintained = load(repo / "support-snapshot.json").get("maintainedBranches", []) + expected_policy_lines = [ + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.", + "-- Do not edit by hand; regenerate when the snapshot changes.", + "return {", + " maintained = {", + *[f' "{branch}",' for branch in maintained], + " },", + "}", + ] + policy_lua = repo / "lib" / "policy.lua" + if not policy_lua.is_file() or policy_lua.read_text().splitlines() != expected_policy_lines: + raise AdmissionError("support snapshot changed without regenerating lib/policy.lua") output.mkdir(parents=True, exist_ok=True) patch = output / "sealed.patch" tracked_patch = subprocess.run( diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 23a0374..15b95b8 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -269,91 +269,107 @@ def test_admission_requires_the_generated_policy_lua_path(self): plan["allowedPaths"] = {"mise-php": ["support-snapshot.json", "lib/policy.lua"]} self.assertTrue(admit(plan, **arguments)["admitted"]) - def test_snapshot_diff_requires_matching_policy_lua(self): - with tempfile.TemporaryDirectory() as temporary: - root = pathlib.Path(temporary) - repo = root / "repo" - (repo / "lib").mkdir(parents=True) - subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) - subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True) - subprocess.run(["git", "config", "user.email", "test@invalid"], cwd=repo, check=True) - accepted = ["8.3", "8.4", "8.5", "8.6"] - superseded = ["8.2", "8.3", "8.4", "8.5"] - policy = root / "support-policy.json" - policy.write_text(json.dumps({"maintainedBranches": accepted}) + "\n") - preconditions = { - "misePhpHead": "d" * 40, - "phpBinPolicyCommit": "a" * 40, - "supportPolicyDigest": digest_file(policy), - "policyInvariantsDigest": "sha256:" + "c" * 64, - "phpBinOperatorCommit": "e" * 40, - "operatorState": "enabled", - } - snapshot = repo / "support-snapshot.json" - policy_lua = repo / "lib" / "policy.lua" - generated = ( - "-- Generated by scripts/generate-policy-lua from support-snapshot.json.\n" - "-- Do not edit by hand; regenerate when the snapshot changes.\n" - "return {\n" - " maintained = {\n" - "%s" - " },\n" - "}\n" - ) - snapshot.write_text(json.dumps({ - "schemaVersion": 1, - "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], - "policyDigest": preconditions["supportPolicyDigest"], - "policyInvariantsDigest": preconditions["policyInvariantsDigest"], - "maintainedBranches": superseded, - "generated": True, - }) + "\n") - policy_lua.write_text(generated % "".join(f' "{branch}",\n' for branch in superseded)) - subprocess.run(["git", "add", "-A"], cwd=repo, check=True) - subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True) - base = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, stdout=subprocess.PIPE - ).stdout.strip() - digests = { - "shared": "sha256:" + "1" * 64, - "phaseTemplate": "sha256:" + "2" * 64, - "eventContract": "sha256:" + "3" * 64, - } - contract = { - "actionKey": "new_branch:8.6", - "preconditions": preconditions, - "completionCriteria": [{"id": "done"}], - } - plan = { + def generated_policy_lua(self, branches): + return ( + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.\n" + "-- Do not edit by hand; regenerate when the snapshot changes.\n" + "return {\n" + " maintained = {\n" + + "".join(f' "{branch}",\n' for branch in branches) + + " },\n" + "}\n" + ) + + # Commits a repo whose base snapshot and lib/policy.lua both list base_branches and + # returns the repo, its base commit, and the remaining seal() arguments by keyword. + def seal_fixture(self, root, accepted, base_branches): + repo = root / "repo" + (repo / "lib").mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@invalid"], cwd=repo, check=True) + policy = root / "support-policy.json" + policy.write_text(json.dumps({"maintainedBranches": accepted}) + "\n") + preconditions = { + "misePhpHead": "d" * 40, + "phpBinPolicyCommit": "a" * 40, + "supportPolicyDigest": digest_file(policy), + "policyInvariantsDigest": "sha256:" + "c" * 64, + "phpBinOperatorCommit": "e" * 40, + "operatorState": "enabled", + } + (repo / "support-snapshot.json").write_text(json.dumps({ + "schemaVersion": 1, + "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], + "policyDigest": preconditions["supportPolicyDigest"], + "policyInvariantsDigest": preconditions["policyInvariantsDigest"], + "maintainedBranches": base_branches, + "generated": True, + }) + "\n") + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(base_branches)) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + digests = { + "shared": "sha256:" + "1" * 64, + "phaseTemplate": "sha256:" + "2" * 64, + "eventContract": "sha256:" + "3" * 64, + } + return repo, base, { + "plan": { "actionKey": "new_branch:8.6", "agentContract": {"instructionDigests": digests}, "preconditions": preconditions, "allowedPaths": {"mise-php": ["support-snapshot.json", "lib/policy.lua"]}, - } - result = { + }, + "result": { "instructionDigests": digests, "phaseStatus": "complete", "criteria": [{"id": "done", "status": "passed", "evidence": ["preconditions.misePhpHead"]}], "unresolved": [], "goNoGo": "go", - } - snapshot.write_text(json.dumps({ - "schemaVersion": 1, - "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], - "policyDigest": preconditions["supportPolicyDigest"], - "policyInvariantsDigest": preconditions["policyInvariantsDigest"], - "maintainedBranches": accepted, - "generated": True, - }) + "\n") + }, + "contract": { + "actionKey": "new_branch:8.6", + "preconditions": preconditions, + "completionCriteria": [{"id": "done"}], + }, + "policy_path": policy, + "output": root / "sealed", + } + + def test_snapshot_diff_requires_matching_policy_lua(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + accepted = ["8.3", "8.4", "8.5", "8.6"] + repo, base, arguments = self.seal_fixture(root, accepted, ["8.2", "8.3", "8.4", "8.5"]) + snapshot = repo / "support-snapshot.json" + document = json.loads(snapshot.read_text()) + document["maintainedBranches"] = accepted + snapshot.write_text(json.dumps(document) + "\n") with self.assertRaises(AdmissionError) as ctx: - seal(repo, base, plan, result, contract, policy, root / "sealed") + seal(repo, base, **arguments) self.assertIn("policy.lua", str(ctx.exception)) - policy_lua.write_text(generated % "".join(f' "{branch}",\n' for branch in accepted)) - manifest = seal(repo, base, plan, result, contract, policy, root / "sealed") + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(accepted)) + manifest = seal(repo, base, **arguments) self.assertEqual( ["lib/policy.lua", "support-snapshot.json"], [item["path"] for item in manifest["files"]] ) + def test_policy_lua_diff_requires_matching_snapshot(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + maintained = ["8.3", "8.4", "8.5", "8.6"] + repo, base, arguments = self.seal_fixture(root, maintained, maintained) + # A lone lib/policy.lua edit would widen the plugin's branch filter with no + # snapshot evidence that php-bin accepted the added branch. + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(maintained + ["9.0"])) + with self.assertRaises(AdmissionError) as ctx: + seal(repo, base, **arguments) + self.assertIn("policy.lua", str(ctx.exception)) + def test_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1] ci = (root / ".github/workflows/ci.yml").read_text() From ae1b3a5ecee733041ef6f0e085f829a948671a08 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:49:58 +0300 Subject: [PATCH 06/30] feat: admit trusted automation readiness records without owner review --- .github/workflows/protected-controls.yml | 59 ++++++++++++++++++++++++ autorelease/admission.py | 31 +++++++++++++ test/test_autorelease.py | 32 ++++++++++++- 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index b762686..acd48a5 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -38,6 +38,9 @@ jobs: echo "number=$PR_NUMBER" echo "head_sha=$(jq -r .head.sha "$RUNNER_TEMP/pr.json")" echo "base_sha=$(jq -r .base.sha "$RUNNER_TEMP/pr.json")" + echo "head_ref=$(jq -r .head.ref "$RUNNER_TEMP/pr.json")" + echo "head_repository=$(jq -r .head.repo.full_name "$RUNNER_TEMP/pr.json")" + echo "author=$(jq -r .user.login "$RUNNER_TEMP/pr.json")" } >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -49,16 +52,26 @@ jobs: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + HEAD_REPOSITORY: ${{ steps.pr.outputs.head_repository }} + PR_AUTHOR: ${{ steps.pr.outputs.author }} PROTECTED_REVIEWER: ${{ vars.AUTORELEASE_OWNER }} run: | python3 - <<'PY' import fnmatch + import base64 import json import os import pathlib + import re import subprocess import sys + sys.path.insert(0, ".") + + from autorelease.admission import AdmissionError, validate_readiness_record + def api(path): result = subprocess.run( ["gh", "api", path, "--paginate", "--slurp"], @@ -71,9 +84,22 @@ jobs: raise RuntimeError("GitHub API returned an invalid paginated response") return [item for page in pages for item in page] + def api_one(path): + result = subprocess.run( + ["gh", "api", path], + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + repo = os.environ["REPOSITORY"] number = os.environ["PR_NUMBER"] head = os.environ["HEAD_SHA"] + base = os.environ["BASE_SHA"] + head_ref = os.environ["HEAD_REF"] + head_repo = os.environ["HEAD_REPOSITORY"] + author = os.environ["PR_AUTHOR"] reviewer = os.environ["PROTECTED_REVIEWER"].lower() manifest = json.loads(pathlib.Path("autorelease/protected-paths.json").read_text()) patterns = manifest["patterns"] @@ -87,6 +113,39 @@ jobs: print("No protected control path changed.") raise SystemExit(0) + readiness_run = re.fullmatch(r"autorelease/readiness-(\d+)", head_ref) + if ( + len(protected) == 1 + and re.fullmatch(r"readiness/[A-Za-z0-9._-]+\.json", protected[0]) + and readiness_run + and author == "github-actions[bot]" + and head_repo.lower() == repo.lower() + ): + commit = api_one(f"repos/{repo}/commits/{head}") + run = api_one(f"repos/{repo}/actions/runs/{readiness_run.group(1)}") + content = api_one(f"repos/{repo}/contents/{protected[0]}?ref={head}") + try: + decoded = base64.b64decode(content["content"].replace("\n", ""), validate=True) + record = json.loads(decoded) + validate_readiness_record(record) + except (KeyError, ValueError, json.JSONDecodeError, AdmissionError) as error: + print(f"Invalid readiness record: {error}", file=sys.stderr) + raise SystemExit(1) from error + expected_filename = record["actionKey"].translate(str.maketrans({":": "-", "/": "-"})) + ".json" + direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base] + trusted_run = ( + protected[0] == f"readiness/{expected_filename}" + and run.get("path") == ".github/workflows/autorelease-consumer.yml" + and run.get("event") in {"schedule", "workflow_dispatch"} + and run.get("head_branch") == "main" + and run.get("status") == "in_progress" + ) + if direct_parent and trusted_run: + print(f"Protected readiness record approved from trusted consumer run {run['id']}.") + raise SystemExit(0) + print("Readiness record did not come from a trusted in-progress consumer run.", file=sys.stderr) + raise SystemExit(1) + reviews = api(f"repos/{repo}/pulls/{number}/reviews") approved = any( review.get("state") == "APPROVED" diff --git a/autorelease/admission.py b/autorelease/admission.py index 677d1be..8ce3771 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -28,6 +28,11 @@ r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" ) SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +READINESS_RECORD_KEYS = { + "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", + "policyDigest", "policyInvariantsDigest", "misePhpCommit", + "evidenceDigests", "recordedAt", +} class AdmissionError(RuntimeError): @@ -75,6 +80,32 @@ def protected(path: str) -> bool: return any(fnmatch.fnmatch(path, pattern) for pattern in PROTECTED) +def validate_readiness_record(record: Any) -> None: + """Exact-shape check for records produced by consumer.readiness().""" + if not isinstance(record, dict) or set(record) != READINESS_RECORD_KEYS: + raise AdmissionError("readiness record has unexpected shape") + if record["schemaVersion"] != 1 or record["state"] != "mise_ready" or record["ready"] is not True: + raise AdmissionError("readiness record has invalid state") + if not ACTION_KEY_RE.fullmatch(str(record["actionKey"])): + raise AdmissionError("readiness record has invalid action key") + for key in ("phpBinPolicyCommit", "misePhpCommit"): + if not re.fullmatch(r"[0-9a-f]{40}", str(record[key])): + raise AdmissionError(f"readiness record {key} is not an exact SHA") + for key in ("policyDigest", "policyInvariantsDigest"): + if not SHA256_RE.fullmatch(str(record[key])): + raise AdmissionError(f"readiness record {key} is not a digest") + digests = record["evidenceDigests"] + if ( + not isinstance(digests, list) + or not digests + or digests != sorted(digests) + or not all(isinstance(item, str) and SHA256_RE.fullmatch(item) for item in digests) + ): + raise AdmissionError("readiness record evidence digests are invalid") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", str(record["recordedAt"])): + raise AdmissionError("readiness record timestamp is invalid") + + def validate_assessment(assessment: dict, contract: dict, digests: dict) -> None: if not isinstance(contract, dict): raise AdmissionError("task contract must be an object") diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 15b95b8..571b5f9 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -5,7 +5,7 @@ import json from unittest import mock -from autorelease import consumer +from autorelease import admission, consumer from autorelease.admission import AdmissionError, admit, digest_file, protected, seal, verify_merge from autorelease.consumer import ( CaptureAbsent, @@ -72,6 +72,36 @@ def test_readiness_requires_exact_commits_and_digests(self): with self.assertRaises(Exception): readiness("new_branch:8.6", "main", "bad", "bad", "main", []) + def test_validate_readiness_record_accepts_consumer_output(self): + record = consumer.readiness( + "new_patch:8.5.9", + "a" * 40, + "sha256:" + "b" * 64, + "sha256:" + "c" * 64, + "d" * 40, + ["sha256:" + "e" * 64], + ) + admission.validate_readiness_record(record) + + def test_validate_readiness_record_rejects_tampering(self): + record = consumer.readiness( + "new_patch:8.5.9", + "a" * 40, + "sha256:" + "b" * 64, + "sha256:" + "c" * 64, + "d" * 40, + ["sha256:" + "e" * 64], + ) + for corrupt in ( + {**record, "ready": False}, + {**record, "state": "published"}, + {**record, "actionKey": "merge:now"}, + {**record, "extra": 1}, + {k: v for k, v in record.items() if k != "evidenceDigests"}, + ): + with self.assertRaises(admission.AdmissionError): + admission.validate_readiness_record(corrupt) + def test_protected_controls_are_not_admissible(self): self.assertTrue(protected(".github/codex-action-contract.json")) self.assertTrue(protected(".github/workflows/autorelease-consumer.yml")) From f32a6c448f10146697af9e868045c4a83b23149a Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:57:07 +0300 Subject: [PATCH 07/30] fix: bind readiness exemption to the merged commit and a single-file diff --- .github/workflows/protected-controls.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index acd48a5..adadd67 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -115,7 +115,8 @@ jobs: readiness_run = re.fullmatch(r"autorelease/readiness-(\d+)", head_ref) if ( - len(protected) == 1 + len(files) == 1 + and len(protected) == 1 and re.fullmatch(r"readiness/[A-Za-z0-9._-]+\.json", protected[0]) and readiness_run and author == "github-actions[bot]" @@ -135,6 +136,7 @@ jobs: direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base] trusted_run = ( protected[0] == f"readiness/{expected_filename}" + and record["misePhpCommit"] == base and run.get("path") == ".github/workflows/autorelease-consumer.yml" and run.get("event") in {"schedule", "workflow_dispatch"} and run.get("head_branch") == "main" From 438a701b5c21b8f4eb5df66565226e3a29a8a1f7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:11:17 +0300 Subject: [PATCH 08/30] fix: match php-bin secret patterns in admission diff scanning --- autorelease/admission.py | 8 +++++++- test/test_autorelease.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/autorelease/admission.py b/autorelease/admission.py index 8ce3771..90eff7a 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -27,6 +27,12 @@ r"repair:\d+\.\d+\.\d+:[0-9a-f]{8,64}|" r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" ) +SECRET_RE = re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" + r"|github_pat_[A-Za-z0-9_]{20,}" + r"|\bgh[opusr]_[A-Za-z0-9]{30,}\b" + r"|\bsk-[A-Za-z0-9_-]{20,}\b" +) SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") READINESS_RECORD_KEYS = { "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", @@ -368,7 +374,7 @@ def seal( text = body.decode("utf-8") except UnicodeDecodeError as error: raise AdmissionError(f"diff entry is not valid UTF-8: {path}") from error - if re.search(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|github_pat_|\\bsk-[A-Za-z0-9_-]{20,}", text): + if SECRET_RE.search(text): raise AdmissionError(f"secret-like material in diff: {path}") if path == "support-snapshot.json": try: diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 571b5f9..00dafd2 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -111,6 +111,17 @@ def test_protected_controls_are_not_admissible(self): self.assertTrue(protected("readiness/new-branch.json")) self.assertFalse(protected("lib/releases.lua")) + def test_secret_scanner_catches_sk_tokens(self): + for secret in ( + "key = sk-" + "a" * 24, + "github_pat_" + "a" * 22, + "ghp_" + "a" * 36, + "-----BEGIN OPENSSH PRIVATE KEY-----", + ): + self.assertIsNotNone(admission.SECRET_RE.search(secret), secret) + for benign in ("task-" + "a" * 24, "github_pat_x", "flask-login"): + self.assertIsNone(admission.SECRET_RE.search(benign), benign) + def test_investigation_defers_required_checks_to_writable_jobs(self): root = pathlib.Path(__file__).resolve().parents[1] instructions = (root / ".github/codex/autorelease/investigation.md").read_text() From 066c4243c67a58942ce7b06c9138d55d8b3849ca Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:20:41 +0300 Subject: [PATCH 09/30] fix: protect gate harness scripts and tests from admitted patches --- .github/CODEOWNERS | 6 ++++++ autorelease/protected-paths.json | 7 ++++++- test/test_autorelease.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 469cf85..db4cf65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,7 +6,13 @@ /autorelease/ @loadinglucian /schemas/ @loadinglucian /scripts/admit-autorelease-plan @loadinglucian +/scripts/dispatch-pr-checks @loadinglucian /scripts/seal-autorelease-patch @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian /scripts/validate-structured-output-schemas @loadinglucian /scripts/verify-merge-admission @loadinglucian +/scripts/test.sh @loadinglucian +/scripts/check-public-language.sh @loadinglucian +/scripts/consume-php-policy @loadinglucian +/scripts/generate-policy-lua @loadinglucian +/test/ @loadinglucian diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index dfa8537..f6d429c 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -15,6 +15,11 @@ "scripts/verify-merge-admission", "autorelease-events/*", "readiness/*", - ".github/CODEOWNERS" + ".github/CODEOWNERS", + "scripts/test.sh", + "scripts/check-public-language.sh", + "scripts/consume-php-policy", + "scripts/generate-policy-lua", + "test/*" ] } diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 00dafd2..d136435 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -111,6 +111,21 @@ def test_protected_controls_are_not_admissible(self): self.assertTrue(protected("readiness/new-branch.json")) self.assertFalse(protected("lib/releases.lua")) + def test_gate_harness_paths_are_protected(self): + for path in ("scripts/test.sh", "scripts/check-public-language.sh", + "scripts/consume-php-policy", "scripts/generate-policy-lua", + "test/test_autorelease.py"): + self.assertTrue(protected(path), path) + # Runtime patches regenerate the policy table, so the generated file stays admissible. + self.assertFalse(protected("lib/policy.lua")) + + def test_codeowners_covers_every_protected_script(self): + patterns = json.loads(pathlib.Path("autorelease/protected-paths.json").read_text())["patterns"] + codeowners = pathlib.Path(".github/CODEOWNERS").read_text() + for pattern in patterns: + if "*" not in pattern: + self.assertIn(f"/{pattern} ", codeowners, pattern) + def test_secret_scanner_catches_sk_tokens(self): for secret in ( "key = sk-" + "a" * 24, From fdfde26c651563e5ddbe9167e31e016195b239ca Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:34:48 +0300 Subject: [PATCH 10/30] fix: restore schema validator parity with php-bin --- autorelease/admission.py | 3 ++- schemas/autorelease-plan.schema.json | 7 +++++-- scripts/validate-structured-output-schemas | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/autorelease/admission.py b/autorelease/admission.py index 90eff7a..7715e97 100755 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -34,6 +34,7 @@ r"|\bsk-[A-Za-z0-9_-]{20,}\b" ) SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +REQUIRED_PLAN_CHECKS = ["Plugin contract"] READINESS_RECORD_KEYS = { "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", "policyDigest", "policyInvariantsDigest", "misePhpCommit", @@ -269,7 +270,7 @@ def admit( raise AdmissionError("plan repository authority is invalid") if plan.get("editsRequired") is not True: raise AdmissionError("changed accepted policy requires a synchronized snapshot edit") - if plan.get("requiredChecks") != ["Plugin contract"]: + if plan.get("requiredChecks") != REQUIRED_PLAN_CHECKS: raise AdmissionError("required deterministic checks changed") if plan.get("risk") not in {"routine", "compatibility", "lifecycle", "recovery", "policy-sensitive"}: raise AdmissionError("invalid plan risk") diff --git a/schemas/autorelease-plan.schema.json b/schemas/autorelease-plan.schema.json index ceb0514..24d74f6 100644 --- a/schemas/autorelease-plan.schema.json +++ b/schemas/autorelease-plan.schema.json @@ -5,7 +5,10 @@ "required": ["schemaVersion", "actionKey", "action", "agentContract", "evidence", "repositories", "preconditions", "editsRequired", "allowedPaths", "requiredChecks", "agentOperations", "budgets", "notification", "risk", "completionAssessment", "summary"], "properties": { "schemaVersion": {"type": "integer", "const": 1}, - "actionKey": {"type": "string"}, + "actionKey": { + "type": "string", + "pattern": "^(new_patch:\\d+\\.\\d+\\.\\d+|new_branch:\\d+\\.\\d+|branch_eol:\\d+\\.\\d+:\\d{4}-\\d{2}-\\d{2}|recipe_rebuild:\\d+\\.\\d+\\.\\d+:[1-9]\\d*|repair:\\d+\\.\\d+\\.\\d+:[0-9a-f]{8,64}|(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" + }, "action": {"type": "string", "enum": ["no_change", "new_patch", "new_branch", "branch_eol", "repair", "reconcile_partial", "blocked", "needs_human"]}, "agentContract": { "type": "object", @@ -72,7 +75,7 @@ "mise-php": {"type": "array", "items": {"type": "string"}} } }, - "requiredChecks": {"type": "array", "items": {"type": "string"}, "const": ["Plugin contract"]}, + "requiredChecks": {"type": "array", "items": {"type": "string", "enum": ["Plugin contract"]}, "minItems": 1, "maxItems": 1}, "agentOperations": {"type": "array", "items": {"type": "string"}}, "budgets": { "type": "object", diff --git a/scripts/validate-structured-output-schemas b/scripts/validate-structured-output-schemas index 565ad93..b4b5980 100755 --- a/scripts/validate-structured-output-schemas +++ b/scripts/validate-structured-output-schemas @@ -14,6 +14,12 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] OUTPUT_SCHEMA_RE = re.compile(r'--output-schema","([^"]+\.json)"') UNSUPPORTED_KEYWORDS = {"uniqueItems"} +sys.path.insert(0, str(ROOT)) +from autorelease.admission import ( # noqa: E402 + ACTION_KEY_RE, + REQUIRED_PLAN_CHECKS, +) + def fail(message: str) -> None: print(f"Structured output schema error: {message}", file=sys.stderr) @@ -44,6 +50,8 @@ def validate_node(node: Any, location: str) -> None: if ("const" in node or "enum" in node) and "type" not in node: fail(f"{location} uses const or enum without an explicit type") + if "const" in node and isinstance(node["const"], (dict, list)): + fail(f"{location} uses a non-scalar const unsupported by OpenAI Structured Outputs") unsupported = sorted(UNSUPPORTED_KEYWORDS.intersection(node)) if unsupported: @@ -70,6 +78,17 @@ def main() -> int: except json.JSONDecodeError as error: fail(f"{path.relative_to(ROOT)} is invalid JSON: {error}") validate_node(document, str(path.relative_to(ROOT))) + if path == ROOT / "schemas/autorelease-plan.schema.json": + properties = document.get("properties", {}) + if properties.get("actionKey", {}).get("pattern") != ACTION_KEY_RE.pattern: + fail("autorelease plan actionKey pattern must match deterministic admission") + required_checks = properties.get("requiredChecks", {}) + if ( + required_checks.get("items", {}).get("enum") != REQUIRED_PLAN_CHECKS + or required_checks.get("minItems") != len(REQUIRED_PLAN_CHECKS) + or required_checks.get("maxItems") != len(REQUIRED_PLAN_CHECKS) + ): + fail("autorelease plan requiredChecks must match deterministic admission") print(f"Validated {len(schema_paths)} Codex Structured Outputs schemas.") return 0 From c70b01eeea3793aa7a87505a8eda79a2e8b72003 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:43:45 +0300 Subject: [PATCH 11/30] refactor: unify merge admission check assertions in one script --- .github/CODEOWNERS | 1 + .github/workflows/autorelease-consumer.yml | 4 ++-- autorelease/protected-paths.json | 1 + scripts/assert-admission-checks | 23 ++++++++++++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100755 scripts/assert-admission-checks diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index db4cf65..41e490e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ /autorelease/ @loadinglucian /schemas/ @loadinglucian /scripts/admit-autorelease-plan @loadinglucian +/scripts/assert-admission-checks @loadinglucian /scripts/dispatch-pr-checks @loadinglucian /scripts/seal-autorelease-patch @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 3523ebe..5f48fba 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -491,7 +491,7 @@ jobs: --pr "$PR_NUMBER" \ --check "Plugin contract" \ --output autorelease-run/pr-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/pr-checks.json + ./scripts/assert-admission-checks --check-name "Plugin contract" --checks autorelease-run/pr-checks.json expected="$(jq -r .headSha autorelease-run/validation.json)" actual="$(gh pr view "$PR_NUMBER" --json headRefOid --jq .headRefOid)" test "$actual" = "$expected" @@ -589,7 +589,7 @@ jobs: --pr "${{ steps.readiness.outputs.number }}" \ --check "Plugin contract" \ --output autorelease-run/readiness-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/readiness-checks.json + ./scripts/assert-admission-checks --check-name "Plugin contract" --checks autorelease-run/readiness-checks.json actual="$(gh pr view "${{ steps.readiness.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "${{ steps.readiness.outputs.head_sha }}" git fetch origin main diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index f6d429c..c613e0f 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -8,6 +8,7 @@ "schemas/*", "autorelease/*", "scripts/admit-autorelease-plan", + "scripts/assert-admission-checks", "scripts/dispatch-pr-checks", "scripts/seal-autorelease-patch", "scripts/validate-codex-action-inputs", diff --git a/scripts/assert-admission-checks b/scripts/assert-admission-checks new file mode 100755 index 0000000..95ef618 --- /dev/null +++ b/scripts/assert-admission-checks @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Asserts dispatch-pr-checks output shows every required admission check +# passing. --require-protected-controls is used by flows whose PRs must not +# touch protected paths; sealed-patch flows omit it because sealed patches +# may edit support-policy.json under seal verification instead. +set -euo pipefail +require_protected="false" +check_name="Script checks" +checks_file="" +while [[ $# -gt 0 ]]; do + case "$1" in + --require-protected-controls) require_protected="true"; shift ;; + --check-name) check_name="$2"; shift 2 ;; + --checks) checks_file="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -n "$checks_file" ]] +jq -e --arg name "$check_name" '[.[] | select(.name==$name) | .bucket] == ["pass"]' "$checks_file" > /dev/null +if [[ "$require_protected" == "true" ]]; then + jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' "$checks_file" > /dev/null +fi +echo "Admission checks passed (protected controls required: $require_protected)." From 0ee8d82ea01441262dc2b39a7b1ecbd0dc9eaf52 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:52:14 +0300 Subject: [PATCH 12/30] fix: correct admission assert rationale and cover check-name path --- scripts/assert-admission-checks | 10 ++++++---- test/test_autorelease.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/assert-admission-checks b/scripts/assert-admission-checks index 95ef618..9c54168 100755 --- a/scripts/assert-admission-checks +++ b/scripts/assert-admission-checks @@ -1,8 +1,10 @@ #!/usr/bin/env bash -# Asserts dispatch-pr-checks output shows every required admission check -# passing. --require-protected-controls is used by flows whose PRs must not -# touch protected paths; sealed-patch flows omit it because sealed patches -# may edit support-policy.json under seal verification instead. +# Re-asserts that the dispatch-pr-checks output records every required +# admission check as passing. The authoritative gate runs inside +# dispatch-pr-checks itself; this script is a belt-and-braces check that +# the file consumed by merge steps still shows the expected verdicts. +# --require-protected-controls marks flows whose pull requests must never +# touch protected paths; sealed-patch flows omit it. set -euo pipefail require_protected="false" check_name="Script checks" diff --git a/test/test_autorelease.py b/test/test_autorelease.py index d136435..e7e8a52 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -150,6 +150,20 @@ def test_investigation_defers_required_checks_to_writable_jobs(self): consumer, ) + def test_assert_admission_checks_covers_the_plugin_contract_bucket(self): + # The consumer merge gates only ever pass --check-name, so this repository's + # copy of the shared script must keep that path working on its own. + script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/assert-admission-checks") + with tempfile.TemporaryDirectory() as temporary: + checks = pathlib.Path(temporary) / "checks.json" + checks.write_text(json.dumps([{"name": "Plugin contract", "bucket": "pass"}])) + subprocess.run([script, "--check-name", "Plugin contract", "--checks", str(checks)], check=True) + checks.write_text(json.dumps([{"name": "Script checks", "bucket": "pass"}])) + result = subprocess.run( + [script, "--check-name", "Plugin contract", "--checks", str(checks)], capture_output=True + ) + self.assertNotEqual(0, result.returncode) + def test_policy_capture_urls_are_commit_pinned(self): sha = "a" * 40 policy, invariants = pinned_policy_urls(sha) From 9c3a36bf7c8c82f2a5444b2c9756c46f29506d78 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 14:38:23 +0300 Subject: [PATCH 13/30] refactor: emit agent task criteria from one script --- .github/CODEOWNERS | 1 + .github/workflows/autorelease-consumer.yml | 26 ++----- autorelease/protected-paths.json | 1 + scripts/prepare-agent-task | 84 ++++++++++++++++++++++ test/test_autorelease.py | 35 +++++++++ 5 files changed, 128 insertions(+), 19 deletions(-) create mode 100755 scripts/prepare-agent-task diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 41e490e..930f7a2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,7 @@ /scripts/admit-autorelease-plan @loadinglucian /scripts/assert-admission-checks @loadinglucian /scripts/dispatch-pr-checks @loadinglucian +/scripts/prepare-agent-task @loadinglucian /scripts/seal-autorelease-patch @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian /scripts/validate-structured-output-schemas @loadinglucian diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 5f48fba..1742021 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -70,6 +70,7 @@ jobs: --arg policyInvariantsDigest "$(jq -r .policyInvariantsDigest autorelease-run/decision.json)" \ --arg phpBinOperatorCommit "${{ steps.operator.outputs.commit }}" \ --arg operatorState "${{ steps.operator.outputs.state }}" \ + --argjson completionCriteria "$(./scripts/prepare-agent-task --phase investigation)" \ '{ contractVersion:1, phase:"investigation", @@ -78,12 +79,7 @@ jobs: preconditions:{misePhpHead:$misePhpHead,phpBinPolicyCommit:$phpBinPolicyCommit,supportPolicyDigest:$supportPolicyDigest,policyInvariantsDigest:$policyInvariantsDigest,phpBinOperatorCommit:$phpBinOperatorCommit,operatorState:$operatorState}, allowedAuthority:["read_repository","read_captured_policy"], nonGoals:["upstream_php_classification","repository_mutation","required_check_execution","irreversible_github_effect"], - completionCriteria:[ - {id:"phase-goal-correct",requirement:"The goal matches exact inputs.",evidenceRequired:"Exact preconditions."}, - {id:"policy-difference-explained",requirement:"Every required local change is bound to captured policy.",evidenceRequired:"Policy digest and JSON locator."}, - {id:"authority-explicit",requirement:"Paths and checks are explicit.",evidenceRequired:"Allowed paths and required checks."}, - {id:"no-unresolved-work",requirement:"No contradiction or stop condition remains.",evidenceRequired:"Empty unresolved list."} - ], + completionCriteria:$completionCriteria, stopConditions:["missing_or_contradictory_policy","changed_precondition","required_protected_change"] }' > autorelease-run/event-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" @@ -166,17 +162,12 @@ jobs: path: autorelease-run - name: Prepare implementation contract and prompt run: | - jq \ + jq --argjson completionCriteria "$(./scripts/prepare-agent-task --phase implementation)" \ '.phase="implementation" | .goal="Implement the admitted mise-php policy synchronization at the exact base." | .allowedAuthority=["workspace_write_admitted_paths","local_advisory_checks"] | .nonGoals=["protected_control_change","irreversible_github_effect"] - | .completionCriteria=[ - {id:"phase-goal-correct",requirement:"Goal and preconditions remain exact.",evidenceRequired:"Plan digest and base SHA."}, - {id:"admitted-diff-complete",requirement:"Diff is complete and admitted.",evidenceRequired:"Final diff and path comparison."}, - {id:"advisory-checks-recorded",requirement:"All checks ran.",evidenceRequired:"Commands and results."}, - {id:"no-unresolved-work",requirement:"No in-scope work remains.",evidenceRequired:"Empty unresolved list."} - ]' \ + | .completionCriteria=$completionCriteria' \ autorelease-run/event-contract.json > autorelease-run/implementation-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" phase="sha256:$(shasum -a 256 .github/codex/autorelease/implementation.md | awk '{print $1}')" @@ -323,17 +314,14 @@ jobs: git apply --index autorelease-run/sealed/sealed.patch fingerprint="sha256:$(shasum -a 256 autorelease-run/authoritative-checks.log | awk '{print $1}')" jq --arg goal "Repair the retained authoritative failure $fingerprint without expanding the admitted diff." \ + --argjson completionCriteria "$(./scripts/prepare-agent-task --phase repair)" \ '.phase="repair" | .goal=$goal | .allowedAuthority=["workspace_write_admitted_paths","local_advisory_checks"] | .nonGoals=["protected_control_change","irreversible_github_effect"] | .stopConditions=["changed_precondition","repeated_or_unrelated_failure","required_protected_change"] - | .completionCriteria=[ - {id:"phase-goal-correct",requirement:"The repair goal matches the exact failure and inputs.",evidenceRequired:"Failure fingerprint and exact base."}, - {id:"failure-cause-removed",requirement:"The named failure is removed without weakening a gate.",evidenceRequired:"Minimal admitted diff and reproduction evidence."}, - {id:"advisory-checks-recorded",requirement:"All advisory checks ran.",evidenceRequired:"Commands and results."}, - {id:"no-unresolved-work",requirement:"No repeated or unrelated failure remains.",evidenceRequired:"Empty unresolved list."} - ]' autorelease-run/implementation-contract.json > autorelease-run/repair-contract.json + | .completionCriteria=$completionCriteria' \ + autorelease-run/implementation-contract.json > autorelease-run/repair-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" phase="sha256:$(shasum -a 256 .github/codex/autorelease/repair.md | awk '{print $1}')" event="sha256:$(shasum -a 256 autorelease-run/repair-contract.json | awk '{print $1}')" diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index c613e0f..44cc744 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -10,6 +10,7 @@ "scripts/admit-autorelease-plan", "scripts/assert-admission-checks", "scripts/dispatch-pr-checks", + "scripts/prepare-agent-task", "scripts/seal-autorelease-patch", "scripts/validate-codex-action-inputs", "scripts/validate-structured-output-schemas", diff --git a/scripts/prepare-agent-task b/scripts/prepare-agent-task new file mode 100755 index 0000000..5c53ef9 --- /dev/null +++ b/scripts/prepare-agent-task @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Emit the reviewed completion criteria for one agent phase.""" + +import argparse +import json + + +CRITERIA = { + "investigation": [ + { + "id": "phase-goal-correct", + "requirement": "The goal matches exact inputs.", + "evidenceRequired": "Exact preconditions.", + }, + { + "id": "policy-difference-explained", + "requirement": "Every required local change is bound to captured policy.", + "evidenceRequired": "Policy digest and JSON locator.", + }, + { + "id": "authority-explicit", + "requirement": "Paths and checks are explicit.", + "evidenceRequired": "Allowed paths and required checks.", + }, + { + "id": "no-unresolved-work", + "requirement": "No contradiction or stop condition remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], + "implementation": [ + { + "id": "phase-goal-correct", + "requirement": "Goal and preconditions remain exact.", + "evidenceRequired": "Plan digest and base SHA.", + }, + { + "id": "admitted-diff-complete", + "requirement": "Diff is complete and admitted.", + "evidenceRequired": "Final diff and path comparison.", + }, + { + "id": "advisory-checks-recorded", + "requirement": "All checks ran.", + "evidenceRequired": "Commands and results.", + }, + { + "id": "no-unresolved-work", + "requirement": "No in-scope work remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], + "repair": [ + { + "id": "phase-goal-correct", + "requirement": "The repair goal matches the exact failure and inputs.", + "evidenceRequired": "Failure fingerprint and exact base.", + }, + { + "id": "failure-cause-removed", + "requirement": "The named failure is removed without weakening a gate.", + "evidenceRequired": "Minimal admitted diff and reproduction evidence.", + }, + { + "id": "advisory-checks-recorded", + "requirement": "All advisory checks ran.", + "evidenceRequired": "Commands and results.", + }, + { + "id": "no-unresolved-work", + "requirement": "No repeated or unrelated failure remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], +} + + +parser = argparse.ArgumentParser() +parser.add_argument("--phase", choices=sorted(CRITERIA), required=True) +args = parser.parse_args() + +# Key order is contractual: the workflow injects this array verbatim, so the emitted +# contract must stay byte-identical to the jq literals this table replaced. +print(json.dumps(CRITERIA[args.phase])) diff --git a/test/test_autorelease.py b/test/test_autorelease.py index e7e8a52..caa4ed0 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -150,6 +150,41 @@ def test_investigation_defers_required_checks_to_writable_jobs(self): consumer, ) + def test_agent_task_criteria_come_from_one_table(self): + # Criteria used to be authored as jq literals in three workflow steps, so the + # only way to check them was matching workflow source text. They now come from + # one script and the emitted JSON is what the agent actually receives. + script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/prepare-agent-task") + expected = { + "investigation": [ + "phase-goal-correct", + "policy-difference-explained", + "authority-explicit", + "no-unresolved-work", + ], + "implementation": [ + "phase-goal-correct", + "admitted-diff-complete", + "advisory-checks-recorded", + "no-unresolved-work", + ], + "repair": [ + "phase-goal-correct", + "failure-cause-removed", + "advisory-checks-recorded", + "no-unresolved-work", + ], + } + for phase, ids in expected.items(): + emitted = json.loads( + subprocess.run([script, "--phase", phase], capture_output=True, check=True).stdout + ) + self.assertEqual(ids, [criterion["id"] for criterion in emitted], phase) + for criterion in emitted: + self.assertEqual(["id", "requirement", "evidenceRequired"], list(criterion), phase) + self.assertTrue(all(criterion.values()), phase) + self.assertNotEqual(0, subprocess.run([script, "--phase", "audit"], capture_output=True).returncode) + def test_assert_admission_checks_covers_the_plugin_contract_bucket(self): # The consumer merge gates only ever pass --check-name, so this repository's # copy of the shared script must keep that path working on its own. From 39816b0984c0b884cef6940f4cec3c46a08983df Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 16:01:39 +0300 Subject: [PATCH 14/30] feat: gate consumer runs on shared-file parity with php-bin --- .github/CODEOWNERS | 1 + .github/workflows/autorelease-consumer.yml | 18 ++++++++++++++++ AUTORELEASE.md | 10 +++++---- autorelease/protected-paths.json | 1 + autorelease/shared-files.json | 12 +++++++++++ scripts/validate-codex-action-inputs | 24 +++++++++++----------- test/test_autorelease.py | 18 ++++++++++++++++ 7 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 autorelease/shared-files.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 930f7a2..8f5a82a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ /.github/codex/ @loadinglucian /.github/codex-action-contract.json @loadinglucian +/.github/dependabot.yml @loadinglucian /.github/workflows/ @loadinglucian /.github/CODEOWNERS @loadinglucian /.codex/ @loadinglucian diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 1742021..dc4b448 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -40,6 +40,24 @@ jobs: test "$state" = "paused" || test "$state" = "enabled" echo "state=$state" >> "$GITHUB_OUTPUT" echo "commit=$(git -C php-operator-control rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Gate the run on shared-file parity with php-bin + env: + GH_TOKEN: ${{ github.token }} + PHP_BIN_COMMIT: ${{ steps.operator.outputs.commit }} + run: | + mapfile -t shared < <(jq -r '.paths[]' autorelease/shared-files.json) + for path in "${shared[@]}"; do + gh api "repos/Bigpixelrocket/php-bin/contents/$path?ref=$PHP_BIN_COMMIT" \ + > "$RUNNER_TEMP/shared-file.json" + # An oversized or symlinked entry comes back unencoded, which would + # otherwise decode to nothing and be reported as drift. + test "$(jq -r .encoding "$RUNNER_TEMP/shared-file.json")" = "base64" + jq -r .content "$RUNNER_TEMP/shared-file.json" | base64 --decode > "$RUNNER_TEMP/shared-file" + if ! cmp -s "$RUNNER_TEMP/shared-file" "$path"; then + echo "Shared file drifted from php-bin at $PHP_BIN_COMMIT: $path" >&2 + exit 1 + fi + done - name: Capture accepted public php-bin policy run: | mkdir -p autorelease-run diff --git a/AUTORELEASE.md b/AUTORELEASE.md index aa699dc..fcf681d 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -8,10 +8,12 @@ The scheduled `php-bin policy consumer` captures the accepted public `support-policy.json` and compares it with `support-snapshot.json`: the policy digest, the invariants digest, the php-bin policy commit, the maintained branches, and any locally incomplete event. It does not fetch or classify -upstream PHP lifecycle data. When the exact policy changes, the -repository-scoped pinned Codex Action produces an evidence-bound plan. Any -implementation runs offline, without a GitHub write credential, and only -against admitted paths. +upstream PHP lifecycle data. The run stops before that capture unless every +path in `autorelease/shared-files.json` is byte-identical with `php-bin` at the +exact commit the operator control was read from. When the exact policy +changes, the repository-scoped pinned Codex Action produces an evidence-bound +plan. Any implementation runs offline, without a GitHub write credential, and +only against admitted paths. ```mermaid flowchart TD diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index 44cc744..98aedcc 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -3,6 +3,7 @@ "patterns": [ ".github/codex/autorelease/*", ".github/codex-action-contract.json", + ".github/dependabot.yml", ".github/workflows/*", ".codex/*", "schemas/*", diff --git a/autorelease/shared-files.json b/autorelease/shared-files.json new file mode 100644 index 0000000..fb5ba78 --- /dev/null +++ b/autorelease/shared-files.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "paths": [ + ".codex/implementation.config.toml", + ".codex/repair.config.toml", + ".github/dependabot.yml", + "scripts/assert-admission-checks", + "scripts/check-public-language.sh", + "scripts/dispatch-pr-checks", + "scripts/validate-codex-action-inputs" + ] +} diff --git a/scripts/validate-codex-action-inputs b/scripts/validate-codex-action-inputs index 9ce931e..9cb9834 100755 --- a/scripts/validate-codex-action-inputs +++ b/scripts/validate-codex-action-inputs @@ -15,6 +15,10 @@ from typing import Any ROOT = pathlib.Path(__file__).resolve().parents[1] CONTRACT_PATH = ROOT / ".github/codex-action-contract.json" BOT_USER_RE = re.compile(r"^[A-Za-z0-9-]+(?:\[bot\])?$") +CANONICAL_CONFIG_RE = re.compile( + r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/config\.toml"' +) +UNLOADED_CONFIG_RE = re.compile(r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/"') def load_workflow(path: pathlib.Path) -> dict[str, Any]: @@ -54,12 +58,7 @@ def validate() -> dict[str, Any]: required_bot_users = set(contract["securityInputs"]["allow-bot-users"]["requiredValues"]) expected_ref = f"{action}@{commit}" invocations = [] - canonical_config_copies = 0 for path in sorted((ROOT / ".github/workflows").glob("*.yml")): - workflow_body = path.read_text() - canonical_config_copies += workflow_body.count('"$RUNNER_TEMP/codex-home/config.toml"') - if re.search(r'cp\s+\.codex/\S+\.config\.toml\s+"\$RUNNER_TEMP/codex-home/"', workflow_body): - raise ValueError(f"{path.relative_to(ROOT)} copies a phase config under an unloaded filename") document = load_workflow(path) jobs = document.get("jobs", {}) if not isinstance(jobs, dict): @@ -67,9 +66,10 @@ def validate() -> dict[str, Any]: for job_name, job in jobs.items(): if not isinstance(job, dict): continue - for index, step in enumerate(job.get("steps", [])): - if not isinstance(step, dict): - continue + steps = [step for step in job.get("steps", []) if isinstance(step, dict)] + for index, step in enumerate(steps): + if UNLOADED_CONFIG_RE.search(step.get("run") or ""): + raise ValueError(f"{path.relative_to(ROOT)} copies a phase config under an unloaded filename") uses = step.get("uses") if not isinstance(uses, str) or not uses.startswith(f"{action}@"): continue @@ -104,15 +104,15 @@ def validate() -> dict[str, Any]: bot_users = {item.strip() for item in allow_bot_users.split(",") if item.strip()} if bot_users != required_bot_users or not all(BOT_USER_RE.fullmatch(item) for item in bot_users): raise ValueError(f"{location} allow-bot-users does not match the reviewed bot allowlist") + # Only a config at the canonical filename is loaded from the + # Codex home, so every invocation needs one earlier in its job. + if not any(CANONICAL_CONFIG_RE.search(earlier.get("run") or "") for earlier in steps[:index]): + raise ValueError(f"{location} starts without a canonical Codex config load in its job") invocations.append({"workflow": str(path.relative_to(ROOT)), "job": job_name, "step": index + 1}) expected_count = contract["expectedInvocations"] if len(invocations) != expected_count: raise ValueError(f"expected {expected_count} Codex Action invocations, found {len(invocations)}") - if canonical_config_copies != expected_count: - raise ValueError( - f"expected {expected_count} canonical Codex config copies, found {canonical_config_copies}" - ) return { "action": action, "commit": commit, diff --git a/test/test_autorelease.py b/test/test_autorelease.py index caa4ed0..c53f2a7 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -126,6 +126,24 @@ def test_codeowners_covers_every_protected_script(self): if "*" not in pattern: self.assertIn(f"/{pattern} ", codeowners, pattern) + def test_shared_file_manifest_gates_the_consumer_run(self): + # ~20 files are duplicated from php-bin and most had drifted silently. The + # manifest declares the intended-identical set; the consumer compares it + # against php-bin at the exact pinned commit before it mutates anything. + root = pathlib.Path(__file__).resolve().parents[1] + manifest = json.loads((root / "autorelease/shared-files.json").read_text()) + self.assertEqual(1, manifest["schemaVersion"]) + paths = manifest["paths"] + self.assertEqual(sorted(set(paths)), paths) + for path in paths: + self.assertTrue((root / path).is_file(), path) + # A shared file an agent may rewrite would fail the gate on the next + # run, so every listed path needs owner review of its own. + self.assertTrue(protected(path), path) + self.assertTrue(protected("autorelease/shared-files.json")) + consumer = (root / ".github/workflows/autorelease-consumer.yml").read_text() + self.assertIn("jq -r '.paths[]' autorelease/shared-files.json", consumer) + def test_secret_scanner_catches_sk_tokens(self): for secret in ( "key = sk-" + "a" * 24, From 7b57c35d439416a96bcb7557bd55d0ad4d322447 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 16:10:01 +0300 Subject: [PATCH 15/30] fix: fail closed when the shared-file manifest is empty or unreadable --- .github/workflows/autorelease-consumer.yml | 6 ++++++ test/test_autorelease.py | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index dc4b448..65eca69 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -46,6 +46,12 @@ jobs: PHP_BIN_COMMIT: ${{ steps.operator.outputs.commit }} run: | mapfile -t shared < <(jq -r '.paths[]' autorelease/shared-files.json) + # mapfile reports success even when the substitution failed, so an + # absent, unparseable, or empty manifest would disable the gate. + if [[ "${#shared[@]}" -eq 0 ]]; then + echo "Shared-file manifest is missing, unparseable, or empty: autorelease/shared-files.json" >&2 + exit 1 + fi for path in "${shared[@]}"; do gh api "repos/Bigpixelrocket/php-bin/contents/$path?ref=$PHP_BIN_COMMIT" \ > "$RUNNER_TEMP/shared-file.json" diff --git a/test/test_autorelease.py b/test/test_autorelease.py index c53f2a7..396fab7 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -135,6 +135,16 @@ def test_shared_file_manifest_gates_the_consumer_run(self): self.assertEqual(1, manifest["schemaVersion"]) paths = manifest["paths"] self.assertEqual(sorted(set(paths)), paths) + # An emptied manifest satisfies every shape assertion while gating nothing, + # so the scripts the gate exists for are named outright. + self.assertLessEqual( + { + "scripts/assert-admission-checks", + "scripts/check-public-language.sh", + "scripts/dispatch-pr-checks", + }, + set(paths), + ) for path in paths: self.assertTrue((root / path).is_file(), path) # A shared file an agent may rewrite would fail the gate on the next @@ -143,6 +153,7 @@ def test_shared_file_manifest_gates_the_consumer_run(self): self.assertTrue(protected("autorelease/shared-files.json")) consumer = (root / ".github/workflows/autorelease-consumer.yml").read_text() self.assertIn("jq -r '.paths[]' autorelease/shared-files.json", consumer) + self.assertIn('if [[ "${#shared[@]}" -eq 0 ]]; then', consumer) def test_secret_scanner_catches_sk_tokens(self): for secret in ( From 90e4f08390a466fbf310092679108814ac2bea23 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 16:37:22 +0300 Subject: [PATCH 16/30] refactor: reuse canonical action filename helper The action-key-to-filename mapping was written out inline in the consumer workflow, and ACTION_KEY_RE was compiled a second time in admission.py. consumer.py now owns action_filename() beside the regex it validates against, and exposes it as the action-filename subcommand the workflow calls. verify-merge-admission ran admission.py as a script, which puts autorelease/ rather than the repository root on sys.path and would break the new import; it becomes the same shim its three siblings already use, and admission.py drops its now-dead __main__ block. --- .github/workflows/autorelease-consumer.yml | 4 ++-- autorelease/admission.py | 23 +++++++++----------- autorelease/consumer.py | 21 ++++++++++++++++++ scripts/verify-merge-admission | 13 +++++++---- test/test_autorelease.py | 25 ++++++++++++++++++++++ 5 files changed, 67 insertions(+), 19 deletions(-) mode change 100755 => 100644 autorelease/admission.py diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 65eca69..06a8861 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -483,7 +483,7 @@ jobs: # action key, but this value reaches $GITHUB_OUTPUT and later shell # steps, so its alphabet is re-asserted at the boundary. [[ "$action_key" =~ ^[A-Za-z0-9._:-]+$ ]] - branch="autorelease/$(printf '%s' "$action_key" | tr ':/' '--')" + branch="autorelease/$(./scripts/consume-php-policy action-filename "$action_key" --suffix '')" gh auth setup-git git push origin "HEAD:refs/heads/$branch" number="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" @@ -568,7 +568,7 @@ jobs: git checkout -B autorelease/readiness-${{ github.run_id }} origin/main base="$(git rev-parse HEAD)" mkdir -p readiness - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./scripts/consume-php-policy action-filename "$ACTION_KEY")" mapfile -t digests < <(jq -r '.captures[].digest' autorelease-run/policy-capture.json) args=() for digest in "${digests[@]}"; do args+=(--evidence-digest "$digest"); done diff --git a/autorelease/admission.py b/autorelease/admission.py old mode 100755 new mode 100644 index 7715e97..dfcce63 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -1,5 +1,9 @@ -#!/usr/bin/env python3 -"""Deterministic admission and sealing for repository-scoped mise changes.""" +"""Deterministic admission and sealing for repository-scoped mise changes. + +This module imports from `autorelease.consumer`, so it is reached only as a package: +`scripts/admit-autorelease-plan`, `scripts/seal-autorelease-patch` and +`scripts/verify-merge-admission` are its command-line entry points. +""" from __future__ import annotations @@ -13,6 +17,10 @@ import sys from typing import Any +# The admissible action-key alphabet is defined once, beside the filename mapping that +# both repositories derive record and branch names from. +from autorelease.consumer import ACTION_KEY_RE + PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") try: @@ -20,13 +28,6 @@ except (OSError, KeyError, TypeError, json.JSONDecodeError) as error: raise RuntimeError(f"cannot load protected paths: {error}") from error PROHIBITED = {"merge", "push", "tag", "release", "publish", "workflow_permissions", "secret_access"} -ACTION_KEY_RE = re.compile( - r"^(new_patch:\d+\.\d+\.\d+|new_branch:\d+\.\d+|" - r"branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2}|" - r"recipe_rebuild:\d+\.\d+\.\d+:[1-9]\d*|" - r"repair:\d+\.\d+\.\d+:[0-9a-f]{8,64}|" - r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" -) SECRET_RE = re.compile( r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" r"|github_pat_[A-Za-z0-9_]{20,}" @@ -555,7 +556,3 @@ def main() -> int: except (AdmissionError, OSError, subprocess.CalledProcessError) as error: print(f"mise autorelease admission rejected: {error}", file=sys.stderr) return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/autorelease/consumer.py b/autorelease/consumer.py index ac335ce..d284ead 100755 --- a/autorelease/consumer.py +++ b/autorelease/consumer.py @@ -51,6 +51,22 @@ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, return super().redirect_request(req, fp, code, msg, headers, newurl) +ACTION_FILENAME_MAP = str.maketrans({":": "-", "/": "-"}) + + +def action_filename(action_key: str, suffix: str = ".json") -> str: + """Return the single file or branch name an action key may occupy. + + php-bin names event records from an action key with exactly this mapping, and the + readiness record it reads back is matched by name, so the two repositories share one + definition of it. The key is model-authored and reaches shell arguments and + repository paths, so its alphabet is re-asserted at this boundary. + """ + if not ACTION_KEY_RE.fullmatch(action_key): + raise ConsumerError(f"invalid action key: {action_key}") + return action_key.translate(ACTION_FILENAME_MAP) + suffix + + def now() -> str: return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -358,6 +374,9 @@ def main() -> int: ready.add_argument("--mise-commit", required=True) ready.add_argument("--evidence-digest", action="append", required=True) ready.add_argument("--output", required=True, type=pathlib.Path) + filename = sub.add_parser("action-filename") + filename.add_argument("action_key") + filename.add_argument("--suffix", default=".json") args = parser.parse_args() try: if args.command == "fetch": @@ -368,6 +387,8 @@ def main() -> int: "captures": fetch_policy_set(args.output, args.invariants_output, args.commit_output), }, ) + elif args.command == "action-filename": + print(action_filename(args.action_key, args.suffix)) elif args.command == "compare": result = compare(args.policy, args.invariants, args.policy_commit, args.snapshot, args.events) write(args.output, result) diff --git a/scripts/verify-merge-admission b/scripts/verify-merge-admission index 2a22dda..963be83 100755 --- a/scripts/verify-merge-admission +++ b/scripts/verify-merge-admission @@ -1,4 +1,9 @@ -#!/usr/bin/env bash -set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -exec python3 "$ROOT/autorelease/admission.py" verify-merge "$@" +#!/usr/bin/env python3 +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +from autorelease.admission import main + +sys.argv.insert(1, "verify-merge") +raise SystemExit(main()) diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 396fab7..4765799 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -102,6 +102,31 @@ def test_validate_readiness_record_rejects_tampering(self): with self.assertRaises(admission.AdmissionError): admission.validate_readiness_record(corrupt) + def test_action_key_alphabet_and_filename_have_one_definition(self): + # admission and consumer both name files and branches from an action key; a + # second copy of either rule drifts silently against php-bin. + # re.compile caches by pattern, so identical copies are indistinguishable at + # runtime; the single definition is only observable in the source. + source = pathlib.Path("autorelease/admission.py").read_text() + self.assertIn("from autorelease.consumer import ACTION_KEY_RE", source) + self.assertNotIn("ACTION_KEY_RE = re.compile", source) + self.assertEqual(admission.ACTION_KEY_RE.pattern, consumer.ACTION_KEY_RE.pattern) + self.assertEqual( + "branch_eol-8.2-2026-12-31.json", consumer.action_filename("branch_eol:8.2:2026-12-31") + ) + self.assertEqual("new_patch-8.5.9", consumer.action_filename("new_patch:8.5.9", "")) + with self.assertRaises(ConsumerError): + consumer.action_filename("../escape") + # The workflow reaches the helper through the same entry point as every other + # consumer subcommand, so the shell sites cannot re-derive the mapping. + result = subprocess.run( + ["./scripts/consume-php-policy", "action-filename", "new_patch:8.5.9"], + check=True, text=True, stdout=subprocess.PIPE, + ) + self.assertEqual("new_patch-8.5.9.json", result.stdout.strip()) + workflow = pathlib.Path(".github/workflows/autorelease-consumer.yml").read_text() + self.assertNotIn("tr ':/'", workflow) + def test_protected_controls_are_not_admissible(self): self.assertTrue(protected(".github/codex-action-contract.json")) self.assertTrue(protected(".github/workflows/autorelease-consumer.yml")) From 962ceb4b5aea5974c1082a654d939b1eba3bfe9d Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:46:28 +0300 Subject: [PATCH 17/30] chore: run every workflow step under bash with pipefail GitHub runs `run:` blocks with `bash -e {0}` unless a shell is named, so a failing command in the middle of a pipeline was silently ignored. Naming bash explicitly switches the runner to `bash --noprofile --norc -eo pipefail {0}`, matching what the scripts already assume. Every pipeline in these workflows was walked first: the remaining ones either feed a command substitution whose exit status was already checked, or read PIPESTATUS under `set +e` and are unaffected by the new setting. --- .github/workflows/autorelease-consumer.yml | 4 ++++ .github/workflows/ci.yml | 4 ++++ .github/workflows/e2e.yml | 4 ++++ .github/workflows/protected-controls.yml | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 06a8861..e29f8b1 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -12,6 +12,10 @@ concurrency: group: mise-php-autorelease-consumer cancel-in-progress: false +defaults: + run: + shell: bash + jobs: investigate: runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e036077..bb5e643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: contract: name: Plugin contract diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cc6491d..9181e3f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: install: name: Install PHP ${{ inputs.version }} diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index adadd67..0d1ad7c 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -13,6 +13,10 @@ permissions: contents: read pull-requests: read +defaults: + run: + shell: bash + jobs: protected-controls: name: Protected controls From 10ff764ebe303d4c6d6e67b8508bd70737b3aebe Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:46:39 +0300 Subject: [PATCH 18/30] fix: name the shared file php-bin returned unencoded The parity gate failed on a bare `test`, which prints nothing, so an oversized or symlinked entry in the manifest produced a red run with no indication of which path caused it. The neighbouring drift failure already names the path. --- .github/workflows/autorelease-consumer.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index e29f8b1..1cc0667 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -61,7 +61,10 @@ jobs: > "$RUNNER_TEMP/shared-file.json" # An oversized or symlinked entry comes back unencoded, which would # otherwise decode to nothing and be reported as drift. - test "$(jq -r .encoding "$RUNNER_TEMP/shared-file.json")" = "base64" + if [[ "$(jq -r .encoding "$RUNNER_TEMP/shared-file.json")" != "base64" ]]; then + echo "Shared file was not returned base64-encoded by php-bin at $PHP_BIN_COMMIT: $path" >&2 + exit 1 + fi jq -r .content "$RUNNER_TEMP/shared-file.json" | base64 --decode > "$RUNNER_TEMP/shared-file" if ! cmp -s "$RUNNER_TEMP/shared-file" "$path"; then echo "Shared file drifted from php-bin at $PHP_BIN_COMMIT: $path" >&2 From 4e228befc22ac67f6d062468ef968aeab08270e9 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:47:04 +0300 Subject: [PATCH 19/30] refactor: drop the local event scan from the policy comparison mise-php never writes an event record: `autorelease-events/` held only a .gitkeep, last touched by a repository rename, so the scan could only ever find nothing and the `event_incomplete` trigger was unreachable. `incompleteActions` was written into decision.json and read by nobody. `autorelease/protected-paths.json` keeps its `autorelease-events/*` entry and its test on purpose: it is a fail-closed guard, and it should keep refusing writes under that prefix whether or not the directory exists. Zero-reference proof after the change, over the whole worktree excluding .git: $ grep -rn "event_incomplete\|incompleteActions\|autorelease-events\|--events" . autorelease/protected-paths.json:19: "autorelease-events/*", test/test_autorelease.py:133: self.assertTrue(protected("autorelease-events/new-patch.json")) php-bin has its own `event_incomplete` in `control.py`'s watch decision; that one is live, backed by a real `autorelease-events/` tree, and is untouched. --- .github/workflows/autorelease-consumer.yml | 3 +-- AUTORELEASE.md | 2 +- autorelease-events/.gitkeep | 1 - autorelease/consumer.py | 19 +++---------------- test/test_autorelease.py | 6 ++---- 5 files changed, 7 insertions(+), 24 deletions(-) delete mode 100644 autorelease-events/.gitkeep diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 1cc0667..69660a2 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -79,7 +79,7 @@ jobs: --invariants-output autorelease-run/policy-invariants.json \ --commit-output autorelease-run/php-bin-main.json \ --manifest autorelease-run/policy-capture.json - - name: Compare only opaque policy and event digests + - name: Compare only opaque policy digests id: compare run: | ./scripts/consume-php-policy compare \ @@ -87,7 +87,6 @@ jobs: --invariants autorelease-run/policy-invariants.json \ --policy-commit autorelease-run/php-bin-main.json \ --snapshot support-snapshot.json \ - --events autorelease-events \ --output autorelease-run/decision.json echo "trigger=$(jq -r .trigger autorelease-run/decision.json)" >> "$GITHUB_OUTPUT" - name: Prepare exact investigation contract diff --git a/AUTORELEASE.md b/AUTORELEASE.md index fcf681d..e27ceec 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -72,7 +72,7 @@ invocation, exact CLI version, and canonical `config.toml` loading against the reviewed offline contract in `.github/codex-action-contract.json` before exercising autorelease behavior. -Inspect `support-snapshot.json`, `autorelease-events/`, `readiness/`, retained +Inspect `support-snapshot.json`, `readiness/`, retained workflow artifacts, and the event's GitHub issue. Recovery corrects the cause and reruns the normal admitted path; it never disables checksum, policy, sealing, exact-SHA, or publication gates. diff --git a/autorelease-events/.gitkeep b/autorelease-events/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/autorelease-events/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/autorelease/consumer.py b/autorelease/consumer.py index d284ead..5a50f45 100755 --- a/autorelease/consumer.py +++ b/autorelease/consumer.py @@ -199,7 +199,6 @@ def compare( invariants: pathlib.Path, policy_commit: pathlib.Path, snapshot: pathlib.Path, - events: pathlib.Path, ) -> dict[str, Any]: policy_digest = digest(policy.read_bytes()) policy_document = load(policy) @@ -282,17 +281,7 @@ def compare( or existing.get("generated") is not True ): raise ConsumerError("local support snapshot has unknown, missing, or invalid fields") - incomplete = [] - if events.exists(): - for path in events.glob("*.json"): - event = load(path) - if event.get("state") not in {"mise_ready", "complete"}: - incomplete.append(event.get("actionKey")) - if len(incomplete) > 1 or any(not ACTION_KEY_RE.fullmatch(value or "") for value in incomplete): - raise ConsumerError("local event state is ambiguous or invalid") - if incomplete: - trigger = "event_incomplete" - elif ( + if ( existing.get("policyDigest") != policy_digest or existing.get("policyInvariantsDigest") != invariants_digest or existing.get("phpBinPolicyCommit") != commit_sha @@ -304,11 +293,10 @@ def compare( return { "schemaVersion": 1, "trigger": trigger, - "actionKey": incomplete[0] if incomplete else policy_document.get("actionKey"), + "actionKey": policy_document.get("actionKey"), "policyDigest": policy_digest, "policyInvariantsDigest": invariants_digest, "phpBinPolicyCommit": commit_sha, - "incompleteActions": sorted(incomplete), "modelCall": trigger != "quiet", } @@ -364,7 +352,6 @@ def main() -> int: compare_parser.add_argument("--invariants", required=True, type=pathlib.Path) compare_parser.add_argument("--policy-commit", required=True, type=pathlib.Path) compare_parser.add_argument("--snapshot", required=True, type=pathlib.Path) - compare_parser.add_argument("--events", required=True, type=pathlib.Path) compare_parser.add_argument("--output", required=True, type=pathlib.Path) ready = sub.add_parser("readiness") ready.add_argument("--action-key", required=True) @@ -390,7 +377,7 @@ def main() -> int: elif args.command == "action-filename": print(action_filename(args.action_key, args.suffix)) elif args.command == "compare": - result = compare(args.policy, args.invariants, args.policy_commit, args.snapshot, args.events) + result = compare(args.policy, args.invariants, args.policy_commit, args.snapshot) write(args.output, result) print(json.dumps(result)) else: diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 4765799..b894929 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -27,8 +27,6 @@ def test_opaque_policy_comparison(self): invariants = root / "invariants.json" commit = root / "commit.json" snapshot = root / "snapshot.json" - events = root / "events" - events.mkdir() invariants.write_text('{"schemaVersion":1,"target":{"os":"macOS","minimumVersion":"26.0","architecture":"arm64","sapi":"cli"},"allowPrereleases":false,"historicalExactVersionsRemainInstallable":true,"immutablePublishedAssets":true}\n') policy.write_text(json.dumps({ "schemaVersion": 1, @@ -47,7 +45,7 @@ def test_opaque_policy_comparison(self): "maintainedBranches": ["8.5"], "generated": True, }) - result = compare(policy, invariants, commit, snapshot, events) + result = compare(policy, invariants, commit, snapshot) self.assertEqual("quiet", result["trigger"]) policy.write_text(json.dumps({ "schemaVersion": 1, @@ -57,7 +55,7 @@ def test_opaque_policy_comparison(self): "actionKey": "bootstrap", "acceptedAt": "2026-07-27T00:00:00Z", }) + "\n") - self.assertEqual("policy_changed", compare(policy, invariants, commit, snapshot, events)["trigger"]) + self.assertEqual("policy_changed", compare(policy, invariants, commit, snapshot)["trigger"]) def test_readiness_requires_exact_commits_and_digests(self): result = readiness( From 99baf61d1f15a6d349f9ed7c62da692cb84f2e1b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:47:17 +0300 Subject: [PATCH 20/30] refactor: check public language over tracked files with one tool The script picked ripgrep when it was installed and grep otherwise, and the two branches disagreed about hidden files, ignore rules, and build output, so the runner's tool inventory decided what was actually checked. Scanning the tracked file list makes the scope the same everywhere. This path is in autorelease/shared-files.json, so the file is byte-identical to php-bin's copy: sha256 143cd773132b1a760da86b6fba6989fe23bd0e1d9d0b64f8a98f5b64a3cea630. --- scripts/check-public-language.sh | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/check-public-language.sh b/scripts/check-public-language.sh index 494630e..0d88172 100755 --- a/scripts/check-public-language.sh +++ b/scripts/check-public-language.sh @@ -6,17 +6,17 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REJECTED_TERM="$(printf '\150\145\162\144')" -if command -v rg >/dev/null 2>&1; then - if rg --hidden --ignore-case --glob '!.git/**' "$REJECTED_TERM" "$PROJECT_ROOT"; then - echo "Public-language check failed." >&2 - exit 1 - fi -else - if grep -Rni --exclude-dir=.git "$REJECTED_TERM" "$PROJECT_ROOT"; then - echo "Public-language check failed." >&2 - exit 1 - fi +# Tracked files are the whole scope. The previous ripgrep and grep branches +# disagreed about hidden files, ignore rules, and build output, so whichever +# tool the runner happened to have installed decided what was checked. +# xargs reports 123 when any grep batch matches nothing, so the finding is read +# from the output rather than from the exit status. +matches="$(cd "$PROJECT_ROOT" && git ls-files -z | xargs -0 grep -HIFni -e "$REJECTED_TERM" || true)" + +if [[ -n "$matches" ]]; then + printf '%s\n' "$matches" >&2 + echo "Public-language check failed." >&2 + exit 1 fi echo "Public-language check passed." - From a6bafa25bf3311d2cdc34635875d223e389521c9 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:48:03 +0300 Subject: [PATCH 21/30] fix: compare admission paths case-sensitively fnmatch.fnmatch runs os.path.normcase on both sides, so on a case-folding platform the protected-path and allowed-path gates would answer differently from the Linux runner that enforces them. Git tracks paths as case-sensitive bytes, so fnmatchcase is the comparison these gates meant all along. --- autorelease/admission.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/autorelease/admission.py b/autorelease/admission.py index dfcce63..7a62804 100644 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -85,7 +85,13 @@ def contained_path(root: pathlib.Path, value: Any, label: str) -> pathlib.Path: def protected(path: str) -> bool: - return any(fnmatch.fnmatch(path, pattern) for pattern in PROTECTED) + """Match a repository path against the protected patterns. + + fnmatchcase, not fnmatch: fnmatch runs os.path.normcase first, which makes the + answer depend on the host platform. Git paths are case-sensitive bytes and this + gate decides admission, so the comparison has to be the same everywhere. + """ + return any(fnmatch.fnmatchcase(path, pattern) for pattern in PROTECTED) def validate_readiness_record(record: Any) -> None: @@ -291,11 +297,11 @@ def admit( if protected(pattern): raise AdmissionError(f"runtime plan admits protected path: {pattern}") flattened.append(pattern) - if not any(fnmatch.fnmatch("support-snapshot.json", pattern) for pattern in flattened): + if not any(fnmatch.fnmatchcase("support-snapshot.json", pattern) for pattern in flattened): raise AdmissionError("policy synchronization does not admit the generated support snapshot") # Sealing rejects a snapshot edit whose lib/policy.lua was not regenerated, so a # plan that cannot carry the regenerated file is unsatisfiable rather than risky. - if not any(fnmatch.fnmatch("lib/policy.lua", pattern) for pattern in flattened): + if not any(fnmatch.fnmatchcase("lib/policy.lua", pattern) for pattern in flattened): raise AdmissionError("policy synchronization does not admit the generated lib/policy.lua") operations = plan.get("agentOperations") if not isinstance(operations, list) or not all(isinstance(item, str) for item in operations): @@ -362,7 +368,7 @@ def seal( files = [] for path in paths: candidate = repo / path - if protected(path) or not any(fnmatch.fnmatch(path, pattern) for pattern in allowed): + if protected(path) or not any(fnmatch.fnmatchcase(path, pattern) for pattern in allowed): raise AdmissionError(f"forbidden diff path: {path}") if candidate.is_symlink() or not candidate.is_file() or candidate.stat().st_size > 2_000_000: raise AdmissionError(f"unsupported diff entry: {path}") From 87f308897e93333758d3cd84714c6ed418acc417 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:49:56 +0300 Subject: [PATCH 22/30] refactor: drop the plan field no consumer step reads The investigation agent was required to produce a `notification` object that mise-php never opens: there is no notify script, no `issues: write` grant, and no step that reads the field out of the plan. Failure notification for both repositories is raised by php-bin's watcher, which reads its own plan. Zero-reference proof after the change, over the whole worktree excluding .git: $ grep -rn "notification\|suggestedSeverity\|humanActionRequired" . (no output) php-bin keeps its own `notification` block; autorelease-watch.yml reads `.notification.summary` from it. --- schemas/autorelease-plan.schema.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/schemas/autorelease-plan.schema.json b/schemas/autorelease-plan.schema.json index 24d74f6..417b2cf 100644 --- a/schemas/autorelease-plan.schema.json +++ b/schemas/autorelease-plan.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "actionKey", "action", "agentContract", "evidence", "repositories", "preconditions", "editsRequired", "allowedPaths", "requiredChecks", "agentOperations", "budgets", "notification", "risk", "completionAssessment", "summary"], + "required": ["schemaVersion", "actionKey", "action", "agentContract", "evidence", "repositories", "preconditions", "editsRequired", "allowedPaths", "requiredChecks", "agentOperations", "budgets", "risk", "completionAssessment", "summary"], "properties": { "schemaVersion": {"type": "integer", "const": 1}, "actionKey": { @@ -87,16 +87,6 @@ "timeoutMinutes": {"type": "integer", "minimum": 1, "maximum": 60} } }, - "notification": { - "type": "object", - "additionalProperties": false, - "required": ["suggestedSeverity", "summary", "humanActionRequired"], - "properties": { - "suggestedSeverity": {"type": "string", "enum": ["info", "warning", "critical"]}, - "summary": {"type": "string"}, - "humanActionRequired": {"type": "boolean"} - } - }, "risk": {"type": "string", "enum": ["routine", "compatibility", "lifecycle", "recovery", "policy-sensitive"]}, "completionAssessment": { "type": "object", From 5d3fa6c05cc863f8b7dec0b6b0ce373b0c06f42a Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:51:38 +0300 Subject: [PATCH 23/30] fix: gate the merge job on a validation outcome, not a job result The merge job ran under `always()`, so a cancelled run still entered the job that pushes the branch, opens the pull request, and merges it. `!cancelled()` keeps the "run even though validate failed and the repair path took over" behaviour without that. It also keyed the repair path on `needs['validate-repair'].result`, which is `success` for any green job, including one that went green without producing the validated artifact this job downloads. `validate-repair` now publishes the same named `passed` output as `validate`, set by a final step that only runs after the artifact upload succeeded. --- .github/workflows/autorelease-consumer.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 69660a2..91e433d 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -407,6 +407,8 @@ jobs: validate-repair: needs: [investigate, repair] + outputs: + passed: ${{ steps.checks.outputs.passed }} runs-on: macos-26 timeout-minutes: 25 permissions: @@ -453,10 +455,15 @@ jobs: if-no-files-found: error retention-days: 90 include-hidden-files: true + # Last step on purpose: the merge job keys on this output, so it must not be + # set until the validated artifact it downloads has actually been uploaded. + - name: Record that the repaired patch validated + id: checks + run: echo "passed=true" >> "$GITHUB_OUTPUT" merge-and-record-readiness: needs: [investigate, validate, validate-repair] - if: always() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].result == 'success') + if: ${{ !cancelled() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].outputs.passed == 'true') }} runs-on: ubuntu-latest timeout-minutes: 25 permissions: From d2d7b3004fc82b33d1e7b7db6c21f56b651da31d Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:51:58 +0300 Subject: [PATCH 24/30] fix: keep the rejected seal when a repair replaces it The repair validation overwrote autorelease-run/sealed in place, so the uploaded artifact ended up claiming the repaired patch had been the sealed one all along and the patch that actually failed validation was gone. Renaming it to sealed-failed keeps both in the evidence while the merge job still finds the bytes it verifies under autorelease-run/sealed. seal() writes exactly sealed.patch and patch-manifest.json, so copying the directory carries the same files the two per-file copies did. --- .github/workflows/autorelease-consumer.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 91e433d..f5ab0f2 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -432,8 +432,12 @@ jobs: git apply --index autorelease-run/sealed-repair/sealed.patch test -z "${OPENAI_API_KEY:-}" ./scripts/test.sh - cp autorelease-run/sealed-repair/sealed.patch autorelease-run/sealed/sealed.patch - cp autorelease-run/sealed-repair/patch-manifest.json autorelease-run/sealed/patch-manifest.json + # The merge job reads autorelease-run/sealed, so the repaired seal has to + # arrive under that name. The rejected seal is renamed rather than + # overwritten: it is the evidence of what the repair replaced, and it ships + # in the same artifact. + mv autorelease-run/sealed autorelease-run/sealed-failed + cp -R autorelease-run/sealed-repair autorelease-run/sealed - name: Create repaired validated commit bundle env: BASE_SHA: ${{ needs.investigate.outputs.base_sha }} From fd3d9cd80f8e1042333df9fe241051f0a2498db9 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:53:14 +0300 Subject: [PATCH 25/30] test: cover the merge admission entry point end to end verify_merge was tested in process, but the merge job calls it through scripts/verify-merge-admission and reads only the exit status. The script fixes the subcommand by editing sys.argv, so a wiring mistake there would let a rejected patch merge with nobody noticing. consume-php-policy already has the same kind of check. --- test/test_autorelease.py | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/test_autorelease.py b/test/test_autorelease.py index b894929..6feba0d 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -322,6 +322,67 @@ def test_merge_gate_binds_single_commit_diff_and_preconditions(self): with self.assertRaises(AdmissionError): verify_merge(root, mutated, manifest, {"Plugin contract": "success"}, state, state) + def test_merge_admission_cli_prints_the_verdict_and_fails_closed(self): + # The merge job reaches the gate through this entry point and reads nothing + # but its exit status, so a rejection that exits 0 would merge an unadmitted + # patch. The in-process test above covers what the gate decides. + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + for arguments in ( + ["init", "-q", "-b", "main"], + ["config", "user.name", "test"], + ["config", "user.email", "test@invalid"], + ): + subprocess.run(["git", *arguments], cwd=root, check=True) + (root / "file.txt").write_text("base\n") + subprocess.run(["git", "add", "file.txt"], cwd=root, check=True) + subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=root, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + (root / "file.txt").write_text("validated\n") + subprocess.run(["git", "add", "file.txt"], cwd=root, check=True) + subprocess.run(["git", "commit", "-q", "-m", "validated"], cwd=root, check=True) + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + paths = { + "manifest": { + "baseSha": base, + "files": [{ + "path": "file.txt", + "digest": digest((root / "file.txt").read_bytes()), + "mode": "0o644", + }], + }, + "checks": {"Plugin contract": "success"}, + "preconditions": {"misePhpHead": base}, + "current": {"misePhpHead": base}, + } + for name, body in paths.items(): + (root / f"{name}.json").write_text(json.dumps(body) + "\n") + + def run_gate(expected_head): + return subprocess.run( + [ + "./scripts/verify-merge-admission", + "--repo", str(root), + "--head", expected_head, + "--manifest", str(root / "manifest.json"), + "--checks", str(root / "checks.json"), + "--preconditions", str(root / "preconditions.json"), + "--current", str(root / "current.json"), + ], + check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + admitted = run_gate(head) + self.assertEqual(0, admitted.returncode, admitted.stderr) + self.assertTrue(json.loads(admitted.stdout)["admitted"]) + rejected = run_gate(base) + self.assertEqual(1, rejected.returncode) + self.assertIn("mise autorelease admission rejected", rejected.stderr) + # Returns an admissible plan plus the remaining admit() arguments by keyword, so # a test can vary one part of the plan without rebuilding the policy capture. def admission_fixture(self, root): From 96779e09279a8404ab8cfd59271957e5d34670b4 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:17:43 +0300 Subject: [PATCH 26/30] fix: fail the public-language check when it lists no files `git ls-files -z | xargs -0 grep ... || true` needed the tolerance for xargs exit 123, which grep produces whenever a batch matches nothing, but the same tolerance covered a failing listing. Run outside a repository the check printed "fatal: not a git repository" and then "Public-language check passed." with exit 0, having scanned nothing. The listing is now produced and checked before the grep runs, in a file because command substitution drops the NUL separators. Probes, in order: outside a repository, a clean repository, a tracked violation, a repository with no tracked files. $ ./scripts/check-public-language.sh # not a repository fatal: not a git repository (or any of the parent directories): .git Public-language check could not list the tracked files of /tmp/probe/nogit. rc=1 $ ./scripts/check-public-language.sh # clean Public-language check passed. rc=0 $ ./scripts/check-public-language.sh # tracked violation doc.txt:1:a HERD of cows Public-language check failed. rc=1 $ ./scripts/check-public-language.sh # empty repository Public-language check found no tracked files in /tmp/probe/empty. rc=1 The same file before this change answered rc 0 with "passed" to the first probe. This path is in autorelease/shared-files.json, so the file is byte-identical to php-bin's copy: sha256 280d32fb10e58baa0b6b364f6e5cc1d122528a8cb7a0f4aa4e504eeb0ba76ea8. --- scripts/check-public-language.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/check-public-language.sh b/scripts/check-public-language.sh index 0d88172..64aea47 100755 --- a/scripts/check-public-language.sh +++ b/scripts/check-public-language.sh @@ -9,9 +9,26 @@ REJECTED_TERM="$(printf '\150\145\162\144')" # Tracked files are the whole scope. The previous ripgrep and grep branches # disagreed about hidden files, ignore rules, and build output, so whichever # tool the runner happened to have installed decided what was checked. +tracked="$(mktemp)" +trap 'rm -f "$tracked"' EXIT + +# The listing is produced and checked on its own. Folded into the grep pipeline it +# hid behind the tolerance that pipeline needs, so a run that listed nothing at all +# still reported a pass. The list is kept in a file because command substitution +# drops the NUL separators that make the names unambiguous. +if ! (cd "$PROJECT_ROOT" && git ls-files -z) > "$tracked"; then + echo "Public-language check could not list the tracked files of $PROJECT_ROOT." >&2 + exit 1 +fi + +if [[ ! -s "$tracked" ]]; then + echo "Public-language check found no tracked files in $PROJECT_ROOT." >&2 + exit 1 +fi + # xargs reports 123 when any grep batch matches nothing, so the finding is read # from the output rather than from the exit status. -matches="$(cd "$PROJECT_ROOT" && git ls-files -z | xargs -0 grep -HIFni -e "$REJECTED_TERM" || true)" +matches="$(cd "$PROJECT_ROOT" && xargs -0 grep -HIFni -e "$REJECTED_TERM" < "$tracked" || true)" if [[ -n "$matches" ]]; then printf '%s\n' "$matches" >&2 From 1c48c2f0c52c665713a9b412a870486c616a7d2b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:05:41 +0300 Subject: [PATCH 27/30] fix: set the validate output only after its artifact uploads validate-repair records passed=true as its last step on purpose: the merge job keys on that output and downloads the artifact, so claiming success before the upload would point merge at something that may not exist. validate set the same output before its uploads, so the stated invariant only held in one of the two jobs. The check step now reports a separate status output that gates the bundle and both uploads, and passed=true is recorded last. A failed run leaves passed unset, which merge and repair already read as not passed. --- .github/workflows/autorelease-consumer.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index f5ab0f2..ae42b05 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -277,7 +277,7 @@ jobs: test "$actual" = "$expected" git apply --index autorelease-run/sealed/sealed.patch - name: Run authoritative plugin checks without OpenAI credential - id: checks + id: run-checks run: | test -z "${OPENAI_API_KEY:-}" set +e @@ -285,12 +285,12 @@ jobs: status="${PIPESTATUS[0]}" set -e if [[ "$status" == "0" ]]; then - echo "passed=true" >> "$GITHUB_OUTPUT" + echo "status=passed" >> "$GITHUB_OUTPUT" else - echo "passed=false" >> "$GITHUB_OUTPUT" + echo "status=failed" >> "$GITHUB_OUTPUT" fi - name: Create reproducible validated commit bundle - if: steps.checks.outputs.passed == 'true' + if: steps.run-checks.outputs.status == 'passed' env: BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | @@ -305,7 +305,7 @@ jobs: '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: steps.checks.outputs.passed == 'true' + if: steps.run-checks.outputs.status == 'passed' with: name: mise-validated-autorelease-patch-${{ github.run_id }} path: autorelease-run/ @@ -313,13 +313,21 @@ jobs: retention-days: 90 include-hidden-files: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: steps.checks.outputs.passed != 'true' + if: steps.run-checks.outputs.status != 'passed' with: name: mise-failed-autorelease-validation-${{ github.run_id }} path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true + # Last step on purpose, exactly as in validate-repair: the merge job keys on + # this output and downloads the validated artifact, so a failed upload must + # leave `passed` unset rather than claim a validated patch is waiting. An + # unset output reads as not-passed to both the merge job and repair. + - name: Record that the patch validated + id: checks + if: steps.run-checks.outputs.status == 'passed' + run: echo "passed=true" >> "$GITHUB_OUTPUT" repair: needs: [investigate, validate] From 3c0a825764aa737e78c841d72d297c91c8900861 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:05:49 +0300 Subject: [PATCH 28/30] docs: scope the notification claim and document unattended lifecycle AUTORELEASE.md described deduplicated assigned issues as if this repository raised them. It does not: php-bin owns notify-autorelease and the jobs holding issues: write, and this consumer requests no issue permission at all, so a failure confined to it reaches the owner through Actions email until php-bin records it. Say so. The supported-branch range was hardcoded as "8.2 through 8.5"; it now points at the snapshot that tracks the accepted policy automatically, and states that EOL delists a branch without making an already-published version less installable. Also record which paths are protected harness and which stay agent-admissible, add an "Unattended lifecycle" section covering both directions, and correct the admin-state snapshot filename to the -after convention actually committed. --- AUTORELEASE.md | 37 +++++++++++++++++++++++++++++++++++++ README.md | 12 ++++++++---- docs/repository-settings.md | 2 +- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/AUTORELEASE.md b/AUTORELEASE.md index e27ceec..80b27a8 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -15,6 +15,18 @@ changes, the repository-scoped pinned Codex Action produces an evidence-bound plan. Any implementation runs offline, without a GitHub write credential, and only against admitted paths. +Which paths those are is the point. The *harness* is protected and never +model-editable: `scripts/test.sh`, `scripts/consume-php-policy`, +`scripts/generate-policy-lua`, `scripts/check-public-language.sh`, the sealing +and admission scripts, `test/`, `autorelease/`, `schemas/`, and +`.github/workflows/`. The *product* stays admissible: `hooks/*.lua`, `lib/`, +`metadata.lua`, and the generated `support-snapshot.json`. A model may change +what the plugin does, never what decides whether it still works, so the +protected plugin-contract tests are the standing control on every product +change. `autorelease-consumer.yml` runs `./scripts/test.sh` from the sealed +model commit for exactly that reason: the gates cannot have been part of the +patch, because admission rejects a protected path before sealing. + ```mermaid flowchart TD policy["Accepted php-bin policy commit and digest"] --> compare{"Snapshot differs?"} @@ -36,6 +48,12 @@ waits for matching `php_bin_ready` and `mise_ready` records at exact commits. Failures and lifecycle transitions use one deduplicated GitHub issue per action key, assigned through `AUTORELEASE_OWNER`. Comments are added only for meaningful changes, and GitHub Actions failure email remains an independent fallback. +That issue is raised and updated by `php-bin`, which owns +`scripts/notify-autorelease` and the jobs holding `issues: write`. This +repository has no notification script and requests no issue permission at all, +so a failure confined to the consumer workflow reaches the owner through the +GitHub Actions failure email alone, until `php-bin` records it against the +action key. ```mermaid flowchart TD @@ -49,6 +67,25 @@ flowchart TD stop --> actions["Actions failure email"] ``` +## Unattended lifecycle + +Tracking a new PHP branch takes zero human input here. No matcher in this +plugin is anchored to a major or minor version, so `8.6`, `9.0`, and `10.0` +need no code change. When the accepted `php-bin` policy adds a branch, the +admitted patch regenerates `support-snapshot.json` and `lib/policy.lua` from +it, the plugin contract tests run against the sealed commit, and the exact +`mise_ready` record commits under `readiness/`. That record merges without a +reviewer because `readiness/` and `autorelease-events/` sit outside CODEOWNERS +by design, while every protected control still cannot merge that way. +`php-bin` publishes the new branch only once its own `php_bin_ready` and this +`mise_ready` record agree at exact commits. + +End of life is the same path in reverse and equally unattended. The branch +leaves the maintained set, so it stops appearing in `mise ls-remote` and stops +resolving from a shorthand such as `php@8.2`. Nothing is removed: an exact +published version such as `8.2.29` still installs, because its `php-bin` +release and checksum assets are immutable. + Pause unattended mutation in the reviewed `php-bin/.github/autorelease-operator.json` control. Read-only capture and investigation remain available while paused. Resume through a reviewed change; diff --git a/README.md b/README.md index 0133634..eb1303a 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,20 @@ It never compiles PHP locally. ## Status The plugin contract, offline end-to-end tests, and installation from published -`php-bin` releases are verified on macOS 26 arm64. Maintained PHP releases for -8.2 through 8.5 are available now. +`php-bin` releases are verified on macOS 26 arm64. Run `mise ls-remote php` for +the versions available right now. ## Requirements - macOS 26 (Tahoe) or newer on arm64 / aarch64 - a current mise release with vfox tool-plugin support -The plugin supports the maintained PHP branches 8.2 through 8.5. PHP branches -that have reached end of life are intentionally not listed or installable. +The plugin supports the maintained PHP branches recorded in +[`support-snapshot.json`](support-snapshot.json), which tracks the accepted +`php-bin` support policy automatically. Branches that have reached end of life +are delisted, so they stop appearing in `mise ls-remote php` and stop resolving +from a branch shorthand. Exact versions published before that point remain +installable, because their `php-bin` releases are immutable. Other operating systems and Intel Macs receive an explicit unsupported-target error. Older macOS releases cannot load the published binaries. diff --git a/docs/repository-settings.md b/docs/repository-settings.md index cea7360..66dbb11 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -46,7 +46,7 @@ still rejects event/readiness paths as agent-authored changes. ```bash ./php-bin/scripts/snapshot-github-admin-state \ --repo bigpixelrocket/mise-php \ - --output mise-php/docs/admin-state/mise-php.json + --output mise-php/docs/admin-state/mise-php-after.json ./php-bin/scripts/configure-github-autorelease \ --repo bigpixelrocket/mise-php \ From d82c70c890d2bb768946986907482028fc9f3ea8 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:20:35 +0300 Subject: [PATCH 29/30] docs: name a published version in the still-installs promise The sentence promising that an already-published version keeps installing used 8.2.29, which was never released. The branch's published tag is 8.2.32. --- AUTORELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AUTORELEASE.md b/AUTORELEASE.md index 80b27a8..f7ce5b3 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -83,7 +83,7 @@ by design, while every protected control still cannot merge that way. End of life is the same path in reverse and equally unattended. The branch leaves the maintained set, so it stops appearing in `mise ls-remote` and stops resolving from a shorthand such as `php@8.2`. Nothing is removed: an exact -published version such as `8.2.29` still installs, because its `php-bin` +published version such as `8.2.32` still installs, because its `php-bin` release and checksum assets are immutable. Pause unattended mutation in the reviewed From 55ba456f37de7e0c57b51df1a87a0dcefba06740 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 20:30:23 +0300 Subject: [PATCH 30/30] fix: group the grep tolerance so the listing failure stays fatal --- scripts/check-public-language.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-public-language.sh b/scripts/check-public-language.sh index 64aea47..b7601d2 100755 --- a/scripts/check-public-language.sh +++ b/scripts/check-public-language.sh @@ -28,7 +28,7 @@ fi # xargs reports 123 when any grep batch matches nothing, so the finding is read # from the output rather than from the exit status. -matches="$(cd "$PROJECT_ROOT" && xargs -0 grep -HIFni -e "$REJECTED_TERM" < "$tracked" || true)" +matches="$(cd "$PROJECT_ROOT" && { xargs -0 grep -HIFni -e "$REJECTED_TERM" < "$tracked" || true; })" if [[ -n "$matches" ]]; then printf '%s\n' "$matches" >&2