From 8427c3ce6ea2dd4a50548c38dafbd88cbe0e4389 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 12:14:28 +0300 Subject: [PATCH 01/48] docs: add autorelease unattended hardening plan --- ...-08-03-autorelease-unattended-hardening.md | 744 ++++++++++++++++++ 1 file changed, 744 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md diff --git a/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md b/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md new file mode 100644 index 0000000..cb06a51 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md @@ -0,0 +1,744 @@ +# Autorelease Unattended Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix every finding from the thermo-nuclear review of the php-bin + mise-php autorelease system and guarantee fully unattended `new_patch`, `new_branch` (minor or major), and `branch_eol` releases with zero human input, while keeping every historical release installable. + +**Architecture:** Two repos. `php-bin` (publisher, `/Users/lucian/Developer/bigpixelrocket/php-bin`) holds the deterministic core (`autorelease/control.py`, `autorelease/verify.py`) plus GitHub Actions workflows that let a Codex agent propose changes which deterministic Python admits, seals, and merges. `mise-php` (consumer, `/Users/lucian/Developer/bigpixelrocket/mise-php`) is a mise plugin in Lua whose autorelease consumer (`autorelease/consumer.py`, `autorelease/admission.py`) propagates php-bin policy. The plan closes automation deadlocks, removes hardcoded 8.x version assumptions, fixes verified defects, plugs authority holes, converts brittle text-grep verification to structural checks, dedupes copy-pasted admission logic, and makes post-publish transactions recoverable. + +**Tech Stack:** Python 3 stdlib (no new deps), Bash + jq, GitHub Actions, Lua (vfox/mise plugin API), `unittest`. + +## Global Constraints + +- Never read, write, search, or reference `**/auth.json`, `**/.env`, `**/.env.*`, `~/.ssh/**`, `~/.aws/**` in any command or code. +- No AI attribution anywhere: no "Generated with", no "Co-Authored-By", nothing referencing AI in code, comments, commits, or PRs. +- Never commit to `main`/`master`. All work on branch `fix/autorelease-unattended-hardening` in each repo. Conventional Commits (`fix:`, `feat:`, `refactor:`, `test:`, `docs:`, `chore:`). +- Never move or delete published tags, releases, or release assets. EOL means "stop producing new builds", never "remove old ones". +- Every user-facing string added must pass `scripts/check-public-language.sh` (runs in both repos' `scripts/test.sh`). +- php-bin gate: `./scripts/test.sh` (the "Script checks" required check). mise-php gate: `./scripts/test.sh` (the "Plugin contract" required check; requires macOS arm64 + `mise` installed — both true on this machine). +- Behavior-preserving refactors and behavior changes go in separate commits. +- After all merges: verify with `./scripts/verify-autorelease-system` in php-bin. +- Merging: use standing admin-bypass approval — verify functional checks first, squash merge, immediately restore any temporarily relaxed protection (for these repos: lift `enforce_admins`, merge, restore). + +--- + +## Phase 0 — Branches + +### Task 0: Create working branches + +**Files:** none (git only) + +- [ ] **Step 1:** In `php-bin`: `git checkout main && git pull && git checkout -b fix/autorelease-unattended-hardening` +- [ ] **Step 2:** In `mise-php`: `git checkout main && git pull && git checkout -b fix/autorelease-unattended-hardening` +- [ ] **Step 3:** Copy this plan into `php-bin/docs/superpowers/plans/` (already there), `git add docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md && git commit -m "docs: add autorelease unattended hardening plan"` + +--- + +## Phase 1 — Unattended functional guarantee + +### Task 1: mise-php — snapshot-driven maintained branches in Lua + +The listing filter `version:match("^8%.[2-5]%.%d+$")` in `lib/releases.lua` and `content:match("(8%.[2-5][^%s]*)")` in `hooks/parse_legacy_file.lua` hardcode branches. A `new_branch:8.6` or `new_branch:9.0` release would never be listed by `mise ls-remote php`, and `branch_eol` would keep listing dead branches. Intended semantics (already asserted by `scripts/test.sh`): **maintained branches are listed; EOL/old versions stay installable via exact version**. + +Fix: generate `lib/policy.lua` from `support-snapshot.json:maintainedBranches`, and have `releases.lua` build its filter from it. `parse_legacy_file.lua` becomes version-agnostic (exact installs of any version are allowed). + +**Files:** +- Create: `mise-php/scripts/generate-policy-lua` +- Create: `mise-php/lib/policy.lua` (generated) +- Modify: `mise-php/lib/releases.lua` (`is_supported_version`) +- Modify: `mise-php/hooks/parse_legacy_file.lua:9` +- Modify: `mise-php/scripts/test.sh` (sync check + generic-branch listing test) + +**Interfaces:** +- Produces: `lib/policy.lua` returning `{ maintained = { "8.2", "8.3", "8.4", "8.5" } }`; `scripts/generate-policy-lua` (no args, reads `support-snapshot.json`, writes `lib/policy.lua`, idempotent). +- Consumed by: Task 6 (admission cross-check), Task 15 (docs). + +- [ ] **Step 1: Write the generator** — `mise-php/scripts/generate-policy-lua`, mode 0755: + +```bash +#!/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 +``` + +- [ ] **Step 2: Generate** — run `./scripts/generate-policy-lua`; confirm `lib/policy.lua` contains the four branches from `support-snapshot.json`. +- [ ] **Step 3: Rewrite `is_supported_version`** in `mise-php/lib/releases.lua` — replace: + +```lua +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 +end +``` + +with: + +```lua +local policy = require("policy") + +function M.is_supported_version(version) + 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 +``` + +(`local policy = require("policy")` goes at the top with the other requires.) + +- [ ] **Step 4: Make legacy-file parsing version-agnostic** — in `mise-php/hooks/parse_legacy_file.lua` replace `local version = content:match("(8%.[2-5][^%s]*)")` with `local version = content:match("(%d+%.%d+[^%s]*)")`. +- [ ] **Step 5: Add the sync check to `scripts/test.sh`** — after the `validate-structured-output-schemas` line add: + +```bash +"$SCRIPT_DIR/generate-policy-lua" +git -C "$PROJECT_ROOT" diff --exit-code lib/policy.lua +``` + +- [ ] **Step 6: Add a generic-branch listing test to `scripts/test.sh`** — the mock server serves whatever archives exist in the assets dir. After the existing `8.1.99` EOL assertions, extend the fixture with a hypothetical next branch to prove listing follows the snapshot, not the code. Immediately after `cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$EOL_ARCHIVE_NAME"` add: + +```bash +FUTURE_ARCHIVE_NAME="php-9.0.1-cli-macos-aarch64.tar.gz" +cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$FUTURE_ARCHIVE_NAME" +``` + +update the `shasum` line to include `"$FUTURE_ARCHIVE_NAME"`, and after the `8.1.99` listing check add: + +```bash +# 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" +``` + +- [ ] **Step 7: Run** `./scripts/test.sh` — expect "Plugin contract test passed." (check the mock server exposes the new archive; if the server only lists archives found on disk this works as-is — read `test/mock_server.py` and if it hardcodes release JSON, extend its fixture list with `9.0.1` the same way `8.1.99` is included). +- [ ] **Step 8: Commit** — `git add lib/policy.lua lib/releases.lua hooks/parse_legacy_file.lua scripts/generate-policy-lua scripts/test.sh test/mock_server.py && git commit -m "feat: derive maintained branches from support snapshot in plugin"` + +### Task 2: mise-php — require policy.lua regeneration in admitted diffs + +Unattended propagation: when the consumer's admitted patch updates `support-snapshot.json`, admission must also require a matching `lib/policy.lua` in the same diff, or a stale filter ships silently. + +**Files:** +- Modify: `mise-php/autorelease/admission.py` (inside the `path == "support-snapshot.json"` branch of the diff validator, around line 339) +- Test: `mise-php/test/test_autorelease.py` + +**Interfaces:** +- Consumes: `lib/policy.lua` format from Task 1 (`maintained = { "", ... }`). + +- [ ] **Step 1: Write the failing test** in `mise-php/test/test_autorelease.py` (match the file's existing fixture-building style — read its existing diff-admission test first and clone its setup): + +```python +def test_snapshot_diff_requires_matching_policy_lua(self): + # Build a valid admitted diff that touches support-snapshot.json but + # leaves lib/policy.lua stale; admission must reject it. + ... # use the file's existing helper that assembles a passing diff case, + # change maintainedBranches to ["8.3", "8.4", "8.5", "8.6"], + # keep lib/policy.lua listing the old branches + with self.assertRaises(admission.AdmissionError) as ctx: + admission.validate_patch(...) # same call the sibling test makes + self.assertIn("policy.lua", str(ctx.exception)) +``` + +(The exact helper names must be copied from the neighboring snapshot test in that file — mirror it exactly; the deliverable is: stale `lib/policy.lua` + changed snapshot ⇒ `AdmissionError` mentioning `policy.lua`.) + +- [ ] **Step 2: Run it** — `python3 -m unittest test.test_autorelease -k policy_lua` — expect FAIL (no error raised). +- [ ] **Step 3: Implement** — in `admission.py`, inside the `if path == "support-snapshot.json":` branch after the snapshot JSON is parsed, add: + +```python +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") +``` + +- [ ] **Step 4: Run the test** — expect PASS. Then run the full suite: `python3 -m unittest discover -s test -p 'test_*.py'`. +- [ ] **Step 5: Commit** — `git commit -am "feat: reject snapshot diffs with stale policy.lua"` + +### Task 3: mise-php — unattended readiness-record merges (deadlock fix) + +`autorelease-consumer.yml` creates a readiness PR touching `readiness/*` — a protected path — but `protected-controls.yml` has **no** automation exemption, so the required "Protected controls" check demands an exact-head owner review. Every consumer run therefore stalls on a human. Port php-bin's trusted-automation exemption pattern (`protected-controls.yml`, the `autorelease-events/*` branch) for readiness records. + +**Files:** +- Modify: `mise-php/autorelease/admission.py` (add `validate_readiness_record`) +- Modify: `mise-php/.github/workflows/protected-controls.yml` (add exemption before the owner-approval fallback) +- Test: `mise-php/test/test_autorelease.py` + +**Interfaces:** +- Produces: `admission.validate_readiness_record(record: dict) -> None` raising `AdmissionError` on any deviation from the shape produced by `consumer.readiness()` (`consumer.py:300-336`). + +- [ ] **Step 1: Write the failing tests**: + +```python +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) +``` + +- [ ] **Step 2: Run** — expect FAIL with `AttributeError: ... no attribute 'validate_readiness_record'`. +- [ ] **Step 3: Implement** in `admission.py`: + +```python +READINESS_RECORD_KEYS = { + "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", + "policyDigest", "policyInvariantsDigest", "misePhpCommit", + "evidenceDigests", "recordedAt", +} + + +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") +``` + +- [ ] **Step 4: Run tests** — expect PASS; run the full unittest suite. +- [ ] **Step 5: Add the workflow exemption** — in `mise-php/.github/workflows/protected-controls.yml`, inside the inline Python after `if not protected: ... SystemExit(0)`, insert (mirroring php-bin's event exemption at `php-bin/.github/workflows/protected-controls.yml:225-260`, including its imports `base64`, `re`, `sys` and the `api_one` helper — copy `api_one` from php-bin verbatim): + +```python +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) +``` + +The step also needs the workflow's env to expose `BASE_SHA`, `HEAD_REF`, `HEAD_REPOSITORY`, `PR_AUTHOR` (copy the exact `env:` keys from php-bin's protected-controls step) and the inline Python needs `head_ref`, `head_repo`, `author`, `base` variables plus `sys.path.insert(0, ".")` / `from autorelease.admission import AdmissionError, validate_readiness_record` — mirror how php-bin's script imports `validate_completed_event_record` from `autorelease.control`. + +- [ ] **Step 6: Static check** — `python3 -c "import yaml"` is unavailable; instead run `ruby -ryaml -e 'YAML.load_file(".github/workflows/protected-controls.yml")'` to confirm the YAML parses, then `./scripts/test.sh`. +- [ ] **Step 7: Commit** — `git commit -am "feat: admit trusted automation readiness records without owner review"` + +### Task 4: php-bin — remove 8.x assumptions from the build/verify path + +`scripts/build.sh:93-97` special-cases `^8\.[2-5]$`. Everything else (ACTION_KEY_RE, seal, events) is already major-agnostic — verified. A `new_branch` patch adds `expected-modules/.txt` (unprotected path — admissible by the runtime agent). + +**Files:** +- Modify: `php-bin/scripts/build.sh:93-97` +- Test: `php-bin/tests/test_autorelease.py` (plan admission for future branches) + +- [ ] **Step 1: Fix build.sh** — replace: + +```bash + PHP_MINOR="${PHP_VERSION%.*}" + if [[ "$PHP_VERSION" =~ ^8\.[2-5]$ ]]; then + PHP_MINOR="$PHP_VERSION" + fi +``` + +with: + +```bash + PHP_MINOR="${PHP_VERSION%.*}" + if [[ "$PHP_VERSION" =~ ^[0-9]+\.[0-9]+$ ]]; then + PHP_MINOR="$PHP_VERSION" + fi +``` + +- [ ] **Step 2: Write the future-branch admission test** in `php-bin/tests/test_autorelease.py` — clone the file's existing `validate_plan` happy-path test (the one using `new_patch:8.5.9`) and parameterize: + +```python +def test_future_branch_action_keys_admitted(self): + for key in ("new_patch:8.6.1", "new_patch:9.0.1", "new_branch:8.6", + "new_branch:9.0", "branch_eol:8.2:2026-12-31"): + self.assertIsNotNone(control.ACTION_KEY_RE.fullmatch(key), key) +``` + +- [ ] **Step 3: Run** — `python3 -m unittest tests.test_autorelease -k future_branch` — expect PASS (regex already generic; this is a regression pin, not TDD red). +- [ ] **Step 4: Run** `./scripts/test.sh` (full php-bin gate). +- [ ] **Step 5: Commit** — `git commit -am "fix: accept any maintained branch in stage-4 module comparison"` + +--- + +## Phase 2 — Verified defects and authority holes + +### Task 5: mise-php — fix the dead secret-scanner arm + +`admission.py:337`: `r"...|github_pat_|\\bsk-[A-Za-z0-9_-]{20,}"` — `\\b` inside a raw string is literal backslash+b, so the `sk-` arm never matches. php-bin's `control.py:65-70` has it right. + +**Files:** +- Modify: `mise-php/autorelease/admission.py:337` +- Test: `mise-php/test/test_autorelease.py` + +- [ ] **Step 1: Failing test** (drive through the module-level regex so the test doesn't need a full diff fixture — extract the pattern to a module constant first, matching php-bin's `SECRET_PATTERNS` style): + +```python +def test_secret_scanner_catches_sk_tokens(self): + self.assertIsNotNone(admission.SECRET_RE.search("key = sk-" + "a" * 24)) + self.assertIsNotNone(admission.SECRET_RE.search("github_pat_x")) + self.assertIsNone(admission.SECRET_RE.search("task-" + "a" * 24)) +``` + +- [ ] **Step 2: Run** — expect FAIL (`SECRET_RE` missing). +- [ ] **Step 3: Implement** — near `ACTION_KEY_RE` add: + +```python +SECRET_RE = re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" + r"|github_pat_" + r"|\bsk-[A-Za-z0-9_-]{20,}" +) +``` + +and replace the inline `re.search(r"-----BEGIN ...", text)` at line 337 with `SECRET_RE.search(text)`. + +- [ ] **Step 4: Run** — sk-token test passes; full suite passes. +- [ ] **Step 5: Commit** — `git commit -am "fix: repair secret scanner word boundary for sk tokens"` + +### Task 6: Both repos — protect the gate harness, regenerate CODEOWNERS + +The admitted runtime agent can currently edit `scripts/test.sh`, `tests/**`, `scripts/build.sh`, `scripts/package.sh`, `scripts/compare-modules.sh` in php-bin (all return `path_is_protected(...) == False`), i.e. it can rewrite the very gates that admit it. CODEOWNERS has also drifted (missing `/scripts/dispatch-pr-checks`, `/scripts/serve-autorelease-artifact`, `/scripts/verify-autorelease-system`). + +Ordering caution: protecting `tests/*` means unattended patches can never edit tests — Task 4 already made the test suite branch-generic, so `new_branch`/`branch_eol` need no test edits. Verify that holds before protecting. + +**Files:** +- Modify: `php-bin/autorelease/protected-paths.json` (add `scripts/test.sh`, `scripts/build.sh`, `scripts/package.sh`, `scripts/compare-modules.sh`, `scripts/check-public-language.sh`, `tests/*`) +- Modify: `php-bin/.github/CODEOWNERS` +- Modify: `mise-php/autorelease/protected-paths.json` (add `scripts/test.sh`, `scripts/check-public-language.sh`, `test/*`, `scripts/consume-php-policy`, `scripts/generate-policy-lua`) +- Modify: `mise-php/.github/CODEOWNERS` +- Test: `php-bin/tests/test_autorelease.py`, `mise-php/test/test_autorelease.py` + +- [ ] **Step 1: Failing test, php-bin**: + +```python +def test_gate_harness_paths_are_protected(self): + for path in ("scripts/test.sh", "scripts/build.sh", "scripts/package.sh", + "scripts/compare-modules.sh", "scripts/check-public-language.sh", + "tests/test_autorelease.py"): + self.assertTrue(control.path_is_protected(path), path) + +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) +``` + +- [ ] **Step 2: Run** — expect FAIL. +- [ ] **Step 3: Implement** — append the new patterns to `php-bin/autorelease/protected-paths.json` `patterns` array; add the missing exact-path lines to `.github/CODEOWNERS` using the same `@owner` as its existing lines (read the file; every line follows `/path @bigpixelrocket-owner-handle`). Add lines for every non-glob pattern currently missing, including the three drifted scripts. +- [ ] **Step 4: Run** php-bin suite + `./scripts/test.sh`. +- [ ] **Step 5: Repeat for mise-php** — same test shape against `admission`'s protected checker (`admission.py` exposes the `protected()`/pattern logic — mirror how its existing protected-path test calls it), same JSON+CODEOWNERS edits with mise-php's path list from **Files** above. +- [ ] **Step 6: Sanity-check unattended flows still admissible** — run existing seal/admission tests in both repos; the runtime patch surface for `new_patch` (`downloads/`-adjacent build inputs, `expected-modules/*`, `support-policy.json` special case) must not intersect the new protections. `python3 -m unittest discover` in both repos. +- [ ] **Step 7: Commit (each repo)** — `git commit -am "fix: protect gate harness scripts and tests from admitted patches"` + +### Task 7: mise-php — restore validator parity with php-bin + +mise-php's `scripts/validate-structured-output-schemas` lost php-bin's non-scalar-`const` rejection and the schema↔constants cross-check; consequently `schemas/implementation-plan.schema.json` carries an array `const` (line ~75) and an unpatterned `actionKey` (line ~8) that php-bin's stricter validator would reject. + +**Files:** +- Modify: `mise-php/scripts/validate-structured-output-schemas` (port the two checks from `php-bin/scripts/validate-structured-output-schemas:54-55` and `:82-105`, adjusted to mise-php's schema/constants module names) +- Modify: `mise-php/schemas/implementation-plan.schema.json` (replace the array `const` with `items`+`enum` the way php-bin's plan schema does; add `"pattern"` to `actionKey` matching `ACTION_KEY_RE`'s source with anchors) +- Test: the validator script itself is the test — it runs in `scripts/test.sh` + +- [ ] **Step 1:** Port the checks (copy php-bin's code blocks; adjust import paths — mise-php constants live in `autorelease/admission.py`/`consumer.py`). +- [ ] **Step 2:** Run `./scripts/validate-structured-output-schemas` — expect FAIL on the two schema defects. +- [ ] **Step 3:** Fix the schema (array const → per-item enum; actionKey pattern anchored `^...$` — derive by copying the regex source string from `admission.py` and verifying with `python3 -c` that both agree on `new_patch:9.0.1`). +- [ ] **Step 4:** Run `./scripts/test.sh` — pass. +- [ ] **Step 5: Commit** — `git commit -am "fix: restore schema validator parity with php-bin"` + +--- + +## Phase 3 — Structural verification instead of text-grep + +### Task 8: php-bin — extract merge-admission check assertions into one script + +Four hand-written jq assertion blocks (watch.yml:277-278, watch.yml:360-361, implement.yml:398+502, publish.yml:361) drifted: watch asserts both "Script checks" and "Protected controls" buckets; implement/publish assert only "Script checks". The implement/publish divergence is *currently required* (sealed patches legitimately touch protected `support-policy.json`, admitted by seal verification, so their protected-controls bucket may be red) — make that divergence declared, not accidental. + +**Files:** +- Create: `php-bin/scripts/assert-admission-checks` +- Modify: `php-bin/.github/workflows/autorelease-watch.yml`, `autorelease-implement.yml`, `autorelease-publish.yml` (replace 4 inline blocks + wire the new script), `php-bin/autorelease/protected-paths.json` (+ CODEOWNERS via Task 6's sync test) +- Modify: `mise-php/scripts/` gets the same script (Task 12 sync manifest covers byte-parity); replace the jq assert in `autorelease-consumer.yml` +- Test: `php-bin/tests/test_autorelease.py` runs the script against fixture JSON + +- [ ] **Step 1: Write the script** — `php-bin/scripts/assert-admission-checks`, mode 0755: + +```bash +#!/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" +checks_file="" +while [[ $# -gt 0 ]]; do + case "$1" in + --require-protected-controls) require_protected="true"; shift ;; + --checks) checks_file="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[[ -n "$checks_file" ]] +jq -e '[.[] | select(.name=="Script checks") | .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)." +``` + +- [ ] **Step 2: Test it** in `tests/test_autorelease.py`: + +```python +def test_assert_admission_checks(self): + ok = [{"name": "Script checks", "bucket": "pass"}, + {"name": "Protected controls", "bucket": "pass"}] + missing_protected = [{"name": "Script checks", "bucket": "pass"}] + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp, "checks.json") + path.write_text(json.dumps(ok)) + subprocess.run(["scripts/assert-admission-checks", "--checks", str(path), + "--require-protected-controls"], check=True) + path.write_text(json.dumps(missing_protected)) + subprocess.run(["scripts/assert-admission-checks", "--checks", str(path)], check=True) + result = subprocess.run(["scripts/assert-admission-checks", "--checks", str(path), + "--require-protected-controls"], capture_output=True) + self.assertNotEqual(result.returncode, 0) +``` + +- [ ] **Step 3: Run test** — PASS. +- [ ] **Step 4: Replace the four inline blocks** — watch.yml's two sites call `./scripts/assert-admission-checks --require-protected-controls --checks `; implement.yml's two and publish.yml's one call it without the flag. Keep each site's `` argument as whatever JSON the surrounding step already produced. Replace mise-php's `jq -e '[.[] | select(.name=="Plugin contract")...` occurrences in `autorelease-consumer.yml` with a mise-php copy of the script whose required check name is parameterized: add `--check-name "Plugin contract"` support (default `"Script checks"`) — one more `case` arm and `--arg` in the jq filter: + +```bash +jq -e --arg name "$check_name" '[.[] | select(.name==$name) | .bucket] == ["pass"]' "$checks_file" > /dev/null +``` + +- [ ] **Step 5:** Add `scripts/assert-admission-checks` to both repos' `protected-paths.json` + CODEOWNERS (Task 6's sync test enforces the latter). +- [ ] **Step 6:** Run both repos' `./scripts/test.sh`; `ruby -ryaml -e 'YAML.load_file(...)'` on each edited workflow. +- [ ] **Step 7: Commit (each repo)** — `git commit -am "refactor: unify merge admission check assertions in one script"` + +### Task 9: php-bin — convert verify.py text assertions to structural checks + +`verify.py` pins workflow *source text*: `release_workflow.count("current-operator.json") >= 3` (:634), `"Unattended mutation is paused" in watch_workflow` (:626), exact `cp .codex/...config.toml` strings (a07), jq literal formatting (:540-547), and `codex-action-contract.json`'s `expectedInvocations` counts string occurrences. These break on any refactor (including this plan's) without catching real regressions. + +**Files:** +- Modify: `php-bin/autorelease/verify.py` (generalize `load_workflow` usage; rewrite the listed assertions) +- Modify: `php-bin/.github/codex-action-contract.json` + its checker if it counts strings (read `scripts/validate-codex-action-inputs` first) +- Test: `./scripts/verify-autorelease-system` (verify.py *is* the test) + +- [ ] **Step 1:** Read `verify.py` assertions a00–a20 and list every assertion that greps YAML source text (the four above plus any found). +- [ ] **Step 2:** For each, rewrite against `load_workflow()` (existing ruby-backed parser at `verify.py:57-65`) — the structural form asserts on parsed steps. Pattern to follow (real example for :626): + +```python +watch = load_workflow(".github/workflows/autorelease-watch.yml") +gate_steps = [ + step + for job in watch["jobs"].values() + for step in job.get("steps", []) + if "unattendedMutation" in (step.get("run") or "") +] +require(gate_steps, "watch workflow must gate on the operator unattended state") +``` + +and for :634 (operator preconditions in publish): + +```python +publish = load_workflow(".github/workflows/autorelease-publish.yml") +operator_steps = [ + step + for job in publish["jobs"].values() + for step in job.get("steps", []) + if "current-operator.json" in (step.get("run") or "") +] +require(len(operator_steps) >= 2, "publish workflow must capture and assert operator state") +``` + +The invariant each assertion protects (operator pause honored; evidence captured; config copied before agent start) must be stated in the `require` message — assert presence and job placement, not byte counts. + +- [ ] **Step 3:** If `expectedInvocations` in `codex-action-contract.json` is enforced by counting substrings, change the checker to count parsed workflow steps whose `uses:` matches the Codex action, and update the contract numbers to match reality per workflow. +- [ ] **Step 4:** Run `./scripts/verify-autorelease-system` — all acceptance checks pass. +- [ ] **Step 5:** Mutation-test one assertion: temporarily rename the operator step in a scratch copy of publish.yml and confirm the structural check fails, then revert. +- [ ] **Step 6: Commit** — `git commit -am "refactor: assert workflow structure instead of source text in verifier"` + +### Task 10: mise-php — same conversion for its text asserts + +`mise-php/test/test_autorelease.py:92-96` asserts jq literals in workflow source; completion-criteria IDs are authored as jq string literals 3× in `autorelease-consumer.yml` while php-bin has a `CRITERIA` table in `scripts/prepare-agent-task:13-80`. + +**Files:** +- Create: `mise-php/scripts/prepare-agent-task` (port php-bin's, with mise-php's criteria IDs — copy the exact IDs from the three jq literals in `autorelease-consumer.yml`) +- Modify: `mise-php/.github/workflows/autorelease-consumer.yml` (replace the three inline criteria constructions with `./scripts/prepare-agent-task` calls, same argument style as php-bin's implement workflow uses) +- Modify: `mise-php/test/test_autorelease.py:92-96` (assert against the script's emitted JSON, not workflow source text) + +- [ ] **Step 1:** Read `php-bin/scripts/prepare-agent-task` fully; read the three criteria sites in `autorelease-consumer.yml`. +- [ ] **Step 2:** Write `mise-php/scripts/prepare-agent-task` mirroring php-bin's structure with mise-php's criteria table. +- [ ] **Step 3:** Failing test: rewrite `test_autorelease.py:92-96` to run `./scripts/prepare-agent-task` for each action kind and assert the criteria IDs in its JSON output (exact IDs copied from the current jq literals). +- [ ] **Step 4:** Wire the workflow; `ruby -ryaml` parse check; `./scripts/test.sh`. +- [ ] **Step 5:** Add `scripts/prepare-agent-task` to mise-php `protected-paths.json` (+ CODEOWNERS). +- [ ] **Step 6: Commit** — `git commit -am "refactor: emit agent task criteria from one script"` + +--- + +## Phase 4 — Transaction recovery (publish atomicity) + +### Task 11: php-bin — resumable post-publish event record + +`autorelease-publish.yml` publishes the immutable release (:281-291) then opens a separate event-record PR (:345-369); a failure between the two leaves a live release with no completed event, and the notify job (:406-444) keys on job result, screaming "critical" even when the release itself succeeded. The watcher already owns a trusted-automation record pattern (`autorelease/eol-complete-*` branches). Extend the watcher to detect *published release missing its completed event record* and file the record itself. + +**Files:** +- Modify: `php-bin/autorelease/control.py` (`watch_decision` — new decision branch) +- Modify: `php-bin/.github/workflows/autorelease-watch.yml` (route the new decision to the same record-PR steps used for eol-complete; branch name `autorelease/event-` matches the existing protected-controls exemption which already accepts `autorelease/(event|eol-complete)-` — **but** its `expected_workflow` maps `event-` to publish.yml, so extend that mapping: watcher-recovered records also arrive on `eol-complete`-style branches; simplest correct move: reuse the `eol-complete` branch prefix for recovery records, which protected-controls already trusts from watch.yml with `schedule`/`workflow_dispatch` events) +- Test: `php-bin/tests/test_autorelease.py` + +- [ ] **Step 1: Failing test** — read `watch_decision`'s existing tests, then add: + +```python +def test_watch_flags_published_release_missing_event_record(self): + # Evidence shows tag 8.5.9 published; event store has no completed + # new_patch:8.5.9 record; watcher must decide to file the record, + # not to start a new release. + decision = control.watch_decision(...) # mirror the sibling test's fixtures, + # with release present + record absent + self.assertEqual(decision["action"], "record_completed_event") + self.assertEqual(decision["actionKey"], "new_patch:8.5.9") +``` + +(The exact fixture shape comes from the neighboring `watch_decision` tests — the deliverable: release-exists-and-record-missing ⇒ `record_completed_event`, ranked before any new-release decision.) + +- [ ] **Step 2: Run** — FAIL (unknown action). +- [ ] **Step 3: Implement** the branch in `watch_decision` (before new-release selection): if evidence proves a published tag whose action key has no completed event record, return `{"action": "record_completed_event", "actionKey": ...}`. +- [ ] **Step 4:** Wire watch.yml: route `record_completed_event` through the existing eol-complete record steps (same `./autorelease/control.py` event-record invocation publish uses, same PR/merge/exemption path on an `autorelease/eol-complete-` branch). The routing change lands in Task 13's extracted router — if executing in order, add it to the router table there; if this task runs first, add a plain `elif` now and migrate in Task 13. +- [ ] **Step 5:** Re-key the publish notify job on transaction state: replace its `if: failure()` (or result-based condition) so it distinguishes "release not published" (critical) from "release published, record pending — watcher will recover" (warning). Concretely: publish writes `autorelease-run/transaction.json` with `{"released": true/false}` after the release step; notify reads it via `actions/download-artifact` and picks the message. Keep the message wording compliant with `check-public-language.sh`. +- [ ] **Step 6:** `./scripts/test.sh`; `ruby -ryaml` parse of watch.yml + publish.yml. +- [ ] **Step 7: Commit** — `git commit -m "feat: recover missing event records from the watcher" && git commit` (split: control.py+tests as `feat:`, workflow wiring as separate `feat:` commit if both large). + +--- + +## Phase 5 — Dedup, dead code, hygiene + +### Task 12: Cross-repo shared-file sync gate + +~20 files are duplicated across repos; 15 have drifted silently. Declare the intended-identical set and gate on it where the network exists (the consumer workflow already fetches php-bin at an exact commit). + +**Files:** +- Create: `mise-php/autorelease/shared-files.json` — list of repo-relative paths intended byte-identical with php-bin (start with: `scripts/dispatch-pr-checks`, `scripts/assert-admission-checks`, `scripts/check-public-language.sh`, plus any file the review found byte-identical today; **exclude** legitimately divergent files) +- Modify: `mise-php/.github/workflows/autorelease-consumer.yml` — in the preflight job (where `current-support-policy.json` is fetched), add a step fetching each shared file at the pinned php-bin commit and comparing digests: + +```bash +jq -r '.paths[]' autorelease/shared-files.json | while read -r path; do + gh api "repos/bigpixelrocket/php-bin/contents/$path?ref=$PHP_BIN_COMMIT" \ + --jq .content | tr -d '\n' | base64 -d > "$RUNNER_TEMP/shared-file" + if ! cmp -s "$RUNNER_TEMP/shared-file" "$path"; then + echo "Shared file drifted from php-bin: $path" >&2 + exit 1 + fi +done +``` + +- Test: `mise-php/test/test_autorelease.py` — `shared-files.json` parses, is sorted, every listed path exists. + +- [ ] **Step 1:** Diff the candidate shared files between repos (`diff php-bin/scripts/dispatch-pr-checks mise-php/scripts/dispatch-pr-checks` etc.); byte-sync the ones that should match (copy php-bin's canonical version over mise-php's), listing each in `shared-files.json`. +- [ ] **Step 2:** Failing test for manifest shape; implement; PASS. +- [ ] **Step 3:** Add the workflow step; `ruby -ryaml` parse; `./scripts/test.sh` in mise-php. +- [ ] **Step 4: Commit** — `git commit -am "feat: gate consumer runs on shared-file parity with php-bin"` + +### Task 13: php-bin — extract the watch dispatch and operator gate + +`watch.yml:211-378` is a 168-line if/elif that silently `exit 0`s on unrouted action combinations (e.g. `repair` with `editsRequired:false`); the operator pause gate is inlined 7× in 3 shapes while `control.mutation_allowed()` sits unreachable; `tr ':/' '--'` filename mapping has 8 definitions. + +**Files:** +- Modify: `php-bin/autorelease/control.py` — add `route_watch_action(decision: dict) -> dict` returning `{"route": "", "actionKey": ...}` and raising `ControlError` on unrouted combinations; add CLI subcommands `route-watch-action`, `operator-gate` (wraps `mutation_allowed`), `action-filename` (wraps the existing `str.maketrans` mapping) +- Modify: `php-bin/.github/workflows/autorelease-watch.yml` — the dispatch becomes: call `./autorelease/control.py route-watch-action` once, then a short `case "$route" in ... esac` with an explicit `*) echo "unrouted action" >&2; exit 1` default +- Modify: all 7 operator-gate inline sites (watch/implement/publish) — replace with `./autorelease/control.py operator-gate --operator-file ` +- Modify: all 8 `tr ':/' '--'` sites — replace with `"$(./autorelease/control.py action-filename "$ACTION_KEY")"` (including mise-php's copies; its Python sites import the one helper from `consumer.py`, which drops the duplicated `ACTION_KEY_RE` in `admission.py` by importing it from `consumer`) +- Test: `php-bin/tests/test_autorelease.py` + +- [ ] **Step 1: Failing tests**: + +```python +def test_route_watch_action_covers_every_decision(self): + # One assertion per legal decision shape, plus: + with self.assertRaises(control.ControlError): + control.route_watch_action({"action": "repair", "editsRequired": False}) + +def test_action_filename(self): + self.assertEqual(control.action_filename("branch_eol:8.2:2026-12-31"), + "branch_eol-8.2-2026-12-31.json") + +def test_operator_gate_blocks_paused_state(self): + self.assertTrue(control.mutation_allowed({"unattendedMutation": "enabled"})) + self.assertFalse(control.mutation_allowed({"unattendedMutation": "paused"})) +``` + +(Adjust `mutation_allowed`'s exact signature to what `control.py:1030` already defines — wire, don't rewrite.) + +- [ ] **Step 2:** Run — FAIL on the new names. +- [ ] **Step 3:** Implement the three functions/subcommands; enumerate every branch of the current watch.yml:211-378 dispatch into `route_watch_action`'s table, with `ControlError` for anything unrouted (this converts today's silent `exit 0` holes into loud failures — enumerate the legal no-op decisions explicitly as `{"route": "none"}` so genuinely idle runs stay green). +- [ ] **Step 4:** Rewire the three workflows and mise-php sites; `ruby -ryaml` parse all; both `./scripts/test.sh` gates. +- [ ] **Step 5: Commit** — `refactor: route watch actions through deterministic control table` (php-bin), `refactor: reuse canonical action filename helper` (mise-php). + +### Task 14: Both repos — dead code, dead schemas, hygiene sweep + +**Files (php-bin):** +- Delete: `schemas/autorelease-event.schema.json`, `schemas/policy-invariants.schema.json`, `schemas/support-policy.schema.json` (verify zero references first: `grep -rn "" --exclude-dir=.build .`) +- Modify: `schemas/agent-completion-assessment.schema.json` — align its plan-fragment duplicate with the canonical plan schema (same constraints, or reference the shared definition the way sibling schemas do) +- Modify: `autorelease/control.py` — dedupe manifest-digest formula (extract `manifest_digest(captures) -> str` used by both `capture_evidence` and `indexed_captures`); use `COMMIT_SHA_RE` at the three inline re-spellings (:751, :845, :851); delete `retry_decision` and `audit_reconstruction` **only if** `grep -rn` shows no callers outside tests, else leave with a docblock stating the caller +- Modify: all 7 php-bin workflows — add top-level `defaults: run: shell: bash` (gives `pipefail` semantics per GitHub's bash invocation), drop the 4 no-op `permissions:` blocks re-declaring defaults, narrow the publish preflight job's permissions to `contents: read` +- Modify: `scripts/check-public-language.sh` — replace the rg-vs-grep dual scope with `git ls-files -z | xargs -0 grep` in both repos +- Modify: `ci.yml:27` — shellcheck glob covers extensionless scripts: `shellcheck scripts/*.sh scripts/dispatch-pr-checks scripts/assert-admission-checks` (list every extensionless bash script explicitly) +- Modify: `scripts/snapshot-github-admin-state:56-58` — import `canonical_json`/`sha256_bytes` from `autorelease.control` instead of reimplementing +- Modify: `scripts/serve-autorelease-artifact` — add a shutdown path (handle SIGTERM, exit cleanly) +- Modify: `scripts/test.sh` — write `.artifacts` under `${RUNNER_TEMP:-$(mktemp -d)}` instead of the working tree +- Modify: `scripts/lib.sh` — replace blanket `# shellcheck disable=SC2034` with per-line disables on the actually-unused vars + +**Files (mise-php):** +- Delete: `autorelease-events/` dead directory + the consumer workflow's `--events autorelease-events` argument + `consumer.py`'s `event_incomplete` machinery (grep-verify no other callers) +- Modify: `autorelease/admission.py` — `from .consumer import ACTION_KEY_RE` replacing its local copy; change `fnmatch.fnmatch` (:75) to `fnmatch.fnmatchcase` (parity with `protected-controls.yml:84`) +- Modify: `schemas/implementation-plan.schema.json` + `AUTORELEASE.md:34-48` — delete the `notification` field nothing reads and the docs section describing the nonexistent notification subsystem +- Modify: `autorelease-consumer.yml:441` — merge job condition becomes `if: ${{ !cancelled() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].outputs.passed == 'true') }}` with `validate-repair` gaining the same named output `passed` as `validate` (stop keying on `.result`); fix the in-place artifact mutation at :415-416 by writing repaired artifacts to a fresh path +- Modify: workflows — same `defaults: run: shell: bash` sweep + +- [ ] **Step 1:** For every deletion, run the grep proving zero references; paste the empty result into the commit message body. +- [ ] **Step 2:** Make the php-bin edits; run `./scripts/test.sh` + `shellcheck` on every touched script. +- [ ] **Step 3:** Make the mise-php edits; run `./scripts/test.sh`. +- [ ] **Step 4:** Run `./scripts/verify-autorelease-system` in php-bin — the Task 9 structural assertions must still pass after the workflow hygiene edits (this is the point of Task 9 landing first). +- [ ] **Step 5: Commits** — separate commits per concern: `chore: delete unreferenced schemas`, `refactor: dedupe digest and sha validation helpers`, `chore: enforce bash defaults and least privilege in workflows`, `fix: make repair merge condition survive skipped validate job`, etc. + +### Task 15: php-bin — decompose control.py behind a façade + +`control.py` is 1,269 lines with ~6 seams. Split into a package while keeping `autorelease/control.py` as the stable import surface (verify.py, tests, workflows all import/invoke it). + +**Files:** +- Create: `php-bin/autorelease/_validation.py` (require/regex/digest primitives), `_admission.py` (validate_plan + seal_patch + verify_merge), `_state.py` (event/release state machines + watch/route decisions), `_evidence.py` (capture client + indexed_captures) +- Modify: `php-bin/autorelease/control.py` — imports + re-exports + `main()` CLI only; every existing public name still importable as `autorelease.control.` +- Test: existing suite is the safety net — zero test-file edits allowed in this task + +- [ ] **Step 1:** Move code verbatim (no behavior edits — this is the refactor-only commit), wire re-exports. +- [ ] **Step 2:** `python3 -m unittest discover` — all pass untouched. +- [ ] **Step 3:** `./scripts/test.sh` and `./scripts/verify-autorelease-system` — pass. +- [ ] **Step 4:** Confirm `autorelease/*` protected-paths glob covers the new files (it does — same directory). +- [ ] **Step 5: Commit** — `git commit -am "refactor: split control module behind stable facade"` + +Also split `validate_plan`'s ~175-line body (control.py:546-719) into per-concern helpers (`_validate_plan_shape`, `_validate_plan_preconditions`, `_validate_plan_actions`) inside `_admission.py` in a **second** commit, still behavior-preserving, suite green. + +--- + +## Phase 6 — Docs and end-to-end proof + +### Task 16: Docs truth pass + full system verification + +**Files:** +- Modify: `mise-php/AUTORELEASE.md:108` (drop the reference to nonexistent `docs/autorelease-verification.md` or create the file it promises), `AUTORELEASE.md:34-48` (done in Task 14 — verify) +- Modify: `php-bin/docs/repository-settings.md:70-72` (correct the snapshot output names to what `scripts/snapshot-github-admin-state` actually emits) +- Modify: both repos' `AUTORELEASE.md` — add a short "Unattended lifecycle" section documenting: new branch (any major/minor) requires zero human input end-to-end (agent patch adds `expected-modules/.txt`, policy + snapshot + `lib/policy.lua` regenerate, readiness/event records merge via trusted-automation exemptions); EOL stops new builds and delists the branch while all published releases remain installable exactly. +- [ ] **Step 1:** `markdownlint` on every touched `.md`. +- [ ] **Step 2:** php-bin: `./scripts/test.sh && ./scripts/verify-autorelease-system`. mise-php: `./scripts/test.sh`. +- [ ] **Step 3: Commit** — `docs: correct autorelease references and document unattended lifecycle` + +### Task 17: Ship + +- [ ] **Step 1:** Push both branches; open PRs (php-bin and mise-php) titled `fix: autorelease unattended hardening`; PR bodies summarize per-phase changes, no AI attribution. +- [ ] **Step 2:** Wait for functional checks ("Script checks" / "Plugin contract" + CI) to pass on both PRs. Note: these PRs touch protected paths, so "Protected controls" will demand owner review that the owner cannot self-approve — per standing approval: lift `enforce_admins`, squash-merge, restore `enforce_admins` immediately (both repos). +- [ ] **Step 3:** Reply to every review-bot finding on the PRs in friendly plain English (no em-dashes). +- [ ] **Step 4:** After merge, trigger `autorelease-e2e.yml` (php-bin) and `e2e.yml` (mise-php) via `gh workflow run`; confirm green. +- [ ] **Step 5:** Run `gh workflow run autorelease-watch.yml` once and confirm the watcher completes with a clean decision (no-op or legitimate action) with zero human gates. + +--- + +## Self-review notes + +- **Spec coverage:** every review finding maps to a task — verified defects (T3, T5, T6-drift, T8-drift), authority holes (T6), structural verifier regressions (T9, T10), duplication (T8, T10, T12, T13, T14), atomicity (T11), dead code/docs (T14, T16), file size (T15), unattended functional gaps (T1, T2, T3, T4, T11). The `scripts/consume-php-policy` unprotected-sibling finding is folded into T6's mise-php pattern list. +- **Known intentional divergence:** mise-php `expectedInvocations` 3 vs php-bin 4 stays divergent — excluded from T12's shared-file list, handled structurally in T9/T10. +- **Ordering constraints:** T4 (branch-generic tests) before T6 (protect tests/); T9 (structural asserts) before T13/T14 (workflow refactors that would break text asserts); T1 before T2 (policy.lua exists before admission requires it). +- **Fixture-dependent test bodies** (T2 step 1, T11 step 1) intentionally defer exact helper names to the sibling tests in the same file — the acceptance criterion in each is stated precisely; implementers must clone the adjacent test's setup rather than invent fixtures. From fba2b25d25ab332392abcdf225b7ec050c20a39d Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:02:29 +0300 Subject: [PATCH 02/48] fix: accept any maintained branch in stage-4 module comparison --- scripts/build.sh | 2 +- tests/test_autorelease.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/build.sh b/scripts/build.sh index ac636a6..5c345a5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -92,7 +92,7 @@ echo "Verified macOS minimum: $MINIMUM_MACOS_VERSION" if [[ "$STAGE" == "s4" ]]; then PHP_MINOR="${PHP_VERSION%.*}" - if [[ "$PHP_VERSION" =~ ^8\.[2-5]$ ]]; then + if [[ "$PHP_VERSION" =~ ^[0-9]+\.[0-9]+$ ]]; then PHP_MINOR="$PHP_VERSION" fi "$SCRIPT_DIR/compare-modules.sh" \ diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 1e3246b..1c8ef09 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -9,6 +9,7 @@ from unittest import mock from autorelease.control import ( + ACTION_KEY_RE, COMPLETION_EVIDENCE_REF_RE, ControlError, canonical_json, @@ -319,6 +320,16 @@ def test_completed_event_record_requires_contiguous_legal_evidenced_history(self with self.assertRaisesRegex(ControlError, "not contiguous"): validate_completed_event_record(record) + def test_future_branch_action_keys_admitted(self): + for key in ( + "new_patch:8.6.1", + "new_patch:9.0.1", + "new_branch:8.6", + "new_branch:9.0", + "branch_eol:8.2:2026-12-31", + ): + self.assertIsNotNone(ACTION_KEY_RE.fullmatch(key), key) + def test_published_asset_mismatch_fails_closed(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) From 145211a8927c8f932a17848dce8df489504b3aa8 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:07:21 +0300 Subject: [PATCH 03/48] fix: validate version shape only in build and package gates --- scripts/build.sh | 4 ++-- scripts/package.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index 5c345a5..205c36a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -14,8 +14,8 @@ STAGE_FILE="$PROJECT_ROOT/stages/$STAGE.txt" SPC_BIN="${SPC_BIN:-$PROJECT_ROOT/.spc/spc}" BUILD_DIR="$PROJECT_ROOT/.build/$PHP_VERSION/$STAGE" -if [[ ! "$PHP_VERSION" =~ ^8\.[2-5](\.[0-9]+)?$ ]]; then - echo "PHP version must be a currently supported 8.2 through 8.5 minor or patch version." >&2 +if [[ ! "$PHP_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + echo "PHP version must be a major.minor branch or an exact patch version." >&2 exit 1 fi diff --git a/scripts/package.sh b/scripts/package.sh index 53a279c..5726c4a 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -14,8 +14,8 @@ fi PHP_BIN="$1" RELEASE_TAG="$2" -if [[ ! "$RELEASE_TAG" =~ ^8\.[2-5]\.[0-9]+(-[1-9][0-9]*)?$ ]]; then - echo "Release tag must look like 8.4.5 or 8.4.5-1." >&2 +if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]]; then + echo "Release tag must be an exact patch version like 8.4.5, optionally with a build number like 8.4.5-1." >&2 exit 2 fi From a1375a1497518c450c7d96cd22c12852965d69c7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:20:39 +0300 Subject: [PATCH 04/48] fix: protect gate harness scripts and tests from admitted patches --- .github/CODEOWNERS | 9 +++++++++ autorelease/protected-paths.json | 8 +++++++- tests/test_autorelease.py | 13 +++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f7cf2f..fdeac72 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,15 +10,24 @@ /scripts/admit-autorelease-plan @loadinglucian /scripts/capture-autorelease-evidence @loadinglucian /scripts/configure-github-autorelease @loadinglucian +/scripts/dispatch-pr-checks @loadinglucian /scripts/autorelease-event @loadinglucian /scripts/notify-autorelease @loadinglucian /scripts/prepare-agent-task @loadinglucian /scripts/seal-autorelease-patch @loadinglucian +/scripts/serve-autorelease-artifact @loadinglucian /scripts/snapshot-github-admin-state @loadinglucian /scripts/validate-autorelease-archive @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian /scripts/validate-structured-output-schemas @loadinglucian +/scripts/verify-autorelease-system @loadinglucian /scripts/verify-merge-admission @loadinglucian /scripts/publish-release @loadinglucian /scripts/watch-autorelease-evidence @loadinglucian /autorelease/policy-invariants.json @loadinglucian +/scripts/test.sh @loadinglucian +/scripts/build.sh @loadinglucian +/scripts/package.sh @loadinglucian +/scripts/compare-modules.sh @loadinglucian +/scripts/check-public-language.sh @loadinglucian +/tests/ @loadinglucian diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index 9bcd8b6..1976bcb 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -28,6 +28,12 @@ "scripts/watch-autorelease-evidence", "autorelease-events/**", "autorelease-state/**", - ".github/CODEOWNERS" + ".github/CODEOWNERS", + "scripts/test.sh", + "scripts/build.sh", + "scripts/package.sh", + "scripts/compare-modules.sh", + "scripts/check-public-language.sh", + "tests/*" ] } diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 1c8ef09..490a775 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -410,6 +410,19 @@ def test_invariants_and_durable_state_are_protected(self): self.assertTrue(path_is_protected("autorelease-state/last-evidence.json")) self.assertFalse(path_is_protected("support-policy.json")) + def test_gate_harness_paths_are_protected(self): + for path in ("scripts/test.sh", "scripts/build.sh", "scripts/package.sh", + "scripts/compare-modules.sh", "scripts/check-public-language.sh", + "tests/test_autorelease.py"): + self.assertTrue(path_is_protected(path), path) + + 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_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1] ci = (root / ".github/workflows/ci.yml").read_text() From 12fa3f407f5131bfbb3cca88361493f9e9326db7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:28:48 +0300 Subject: [PATCH 05/48] fix: protect toolchain pins and the shared shell library from admitted patches --- .github/CODEOWNERS | 5 +++++ autorelease/protected-paths.json | 7 ++++++- tests/test_autorelease.py | 8 +++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fdeac72..3584fa8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -31,3 +31,8 @@ /scripts/compare-modules.sh @loadinglucian /scripts/check-public-language.sh @loadinglucian /tests/ @loadinglucian +/scripts/lib.sh @loadinglucian +/scripts/install-spc.sh @loadinglucian +/scripts/install-build-deps.sh @loadinglucian +/.spc-version @loadinglucian +/.spc-sha256 @loadinglucian diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index 1976bcb..99abc01 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -34,6 +34,11 @@ "scripts/package.sh", "scripts/compare-modules.sh", "scripts/check-public-language.sh", - "tests/*" + "tests/*", + "scripts/lib.sh", + "scripts/install-spc.sh", + "scripts/install-build-deps.sh", + ".spc-version", + ".spc-sha256" ] } diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 490a775..b4fce3b 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -413,7 +413,13 @@ def test_invariants_and_durable_state_are_protected(self): def test_gate_harness_paths_are_protected(self): for path in ("scripts/test.sh", "scripts/build.sh", "scripts/package.sh", "scripts/compare-modules.sh", "scripts/check-public-language.sh", - "tests/test_autorelease.py"): + "tests/test_autorelease.py", + # Sourced by the protected gate scripts, so agent-authored bash would + # otherwise execute inside the gate run that judges the patch. + "scripts/lib.sh", + # Pin the compiler toolchain that produces published binaries. + "scripts/install-spc.sh", "scripts/install-build-deps.sh", + ".spc-version", ".spc-sha256"): self.assertTrue(path_is_protected(path), path) def test_codeowners_covers_every_protected_script(self): From 368dbee3df4eee34aaf3354125d64d0b342d5615 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:43:45 +0300 Subject: [PATCH 06/48] refactor: unify merge admission check assertions in one script --- .github/CODEOWNERS | 1 + .github/workflows/autorelease-implement.yml | 4 ++-- .github/workflows/autorelease-publish.yml | 2 +- .github/workflows/autorelease-watch.yml | 6 ++---- autorelease/protected-paths.json | 1 + scripts/assert-admission-checks | 23 +++++++++++++++++++++ tests/test_autorelease.py | 16 ++++++++++++++ 7 files changed, 46 insertions(+), 7 deletions(-) create mode 100755 scripts/assert-admission-checks diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3584fa8..977664a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,7 @@ /autorelease/ @loadinglucian /schemas/ @loadinglucian /scripts/admit-autorelease-plan @loadinglucian +/scripts/assert-admission-checks @loadinglucian /scripts/capture-autorelease-evidence @loadinglucian /scripts/configure-github-autorelease @loadinglucian /scripts/dispatch-pr-checks @loadinglucian diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index f6ec9d4..4ccb62b 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -395,7 +395,7 @@ jobs: --pr "${{ steps.pr.outputs.number }}" \ --check "Script checks" \ --output autorelease-run/pr-checks.json - jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' autorelease-run/pr-checks.json + ./scripts/assert-admission-checks --checks autorelease-run/pr-checks.json - name: Re-verify exact SHA, sealed tree, and preconditions env: GH_TOKEN: ${{ github.token }} @@ -499,7 +499,7 @@ jobs: --pr "${{ steps.readiness.outputs.number }}" \ --check "Script checks" \ --output autorelease-run/readiness-checks.json - jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' autorelease-run/readiness-checks.json + ./scripts/assert-admission-checks --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/.github/workflows/autorelease-publish.yml b/.github/workflows/autorelease-publish.yml index 0799e79..2c50f38 100644 --- a/.github/workflows/autorelease-publish.yml +++ b/.github/workflows/autorelease-publish.yml @@ -358,7 +358,7 @@ jobs: --pr "${{ steps.event_pr.outputs.number }}" \ --check "Script checks" \ --output release-run/event-checks.json - jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' release-run/event-checks.json + ./scripts/assert-admission-checks --checks release-run/event-checks.json actual="$(gh pr view "${{ steps.event_pr.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "${{ steps.event_pr.outputs.head_sha }}" git fetch origin main diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 42c2caa..4af3a1a 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -274,8 +274,7 @@ jobs: --pr "$number" \ --check "Script checks" \ --output autorelease-plan-download/no-change-checks.json - jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' autorelease-plan-download/no-change-checks.json - jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' autorelease-plan-download/no-change-checks.json + ./scripts/assert-admission-checks --require-protected-controls --checks autorelease-plan-download/no-change-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" git fetch origin main test "$(git rev-parse origin/main)" = "$base" @@ -357,8 +356,7 @@ jobs: --pr "$number" \ --check "Script checks" \ --output autorelease-plan-download/eol-checks.json - jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' autorelease-plan-download/eol-checks.json - jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' autorelease-plan-download/eol-checks.json + ./scripts/assert-admission-checks --require-protected-controls --checks autorelease-plan-download/eol-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" git fetch origin main test "$(git rev-parse origin/main)" = "$base" diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index 99abc01..1f9f91a 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -10,6 +10,7 @@ "schemas/**", "autorelease/**", "scripts/admit-autorelease-plan", + "scripts/assert-admission-checks", "scripts/capture-autorelease-evidence", "scripts/configure-github-autorelease", "scripts/dispatch-pr-checks", 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)." diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index b4fce3b..8cb22ac 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -468,6 +468,22 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): release.index("Notify owner of completed release"), ) + def test_assert_admission_checks(self): + script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/assert-admission-checks") + ok = [{"name": "Script checks", "bucket": "pass"}, + {"name": "Protected controls", "bucket": "pass"}] + missing_protected = [{"name": "Script checks", "bucket": "pass"}] + with tempfile.TemporaryDirectory() as temporary: + path = pathlib.Path(temporary, "checks.json") + path.write_text(json.dumps(ok)) + subprocess.run([script, "--checks", str(path), + "--require-protected-controls"], check=True) + path.write_text(json.dumps(missing_protected)) + subprocess.run([script, "--checks", str(path)], check=True) + result = subprocess.run([script, "--checks", str(path), + "--require-protected-controls"], capture_output=True) + self.assertNotEqual(result.returncode, 0) + def test_malformed_contract_shapes_fail_closed(self): contract = self._contract() contract["allowedAuthority"] = [[]] From 9a899ea1c31e1091939cfa4ff650899a8785949d Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 13:52:14 +0300 Subject: [PATCH 07/48] fix: correct admission assert rationale and cover check-name path --- scripts/assert-admission-checks | 10 ++++++---- tests/test_autorelease.py | 9 +++++++++ 2 files changed, 15 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/tests/test_autorelease.py b/tests/test_autorelease.py index 8cb22ac..98bc182 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -484,6 +484,15 @@ def test_assert_admission_checks(self): "--require-protected-controls"], capture_output=True) self.assertNotEqual(result.returncode, 0) + # mise-php merge gates only ever assert this renamed bucket. + path.write_text(json.dumps([{"name": "Plugin contract", "bucket": "pass"}])) + subprocess.run([script, "--checks", str(path), + "--check-name", "Plugin contract"], check=True) + path.write_text(json.dumps(missing_protected)) + result = subprocess.run([script, "--checks", str(path), + "--check-name", "Plugin contract"], capture_output=True) + self.assertNotEqual(result.returncode, 0) + def test_malformed_contract_shapes_fail_closed(self): contract = self._contract() contract["allowedAuthority"] = [[]] From eb91008a1fdcfcbe50f7023216a5c8bf4e808fbd Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 14:05:05 +0300 Subject: [PATCH 08/48] refactor: assert workflow structure instead of source text in verifier --- autorelease/verify.py | 188 ++++++++++++++++++++++----- scripts/validate-codex-action-inputs | 24 ++-- 2 files changed, 165 insertions(+), 47 deletions(-) diff --git a/autorelease/verify.py b/autorelease/verify.py index b188c2f..94f2569 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -41,6 +41,10 @@ PHP_ROOT = pathlib.Path(__file__).resolve().parents[1] PIN_RE = re.compile(r"^\s*uses:\s*[^#\s]+@([0-9a-f]{40})(?:\s*#.*)?$", re.MULTILINE) UNPINNED_RE = re.compile(r"^\s*uses:\s*[^#\s]+@(?![0-9a-f]{40}(?:\s|$))[^#\s]+", re.MULTILINE) +CODEX_ACTION = "openai/codex-action@" +CANONICAL_CODEX_CONFIG = re.compile( + r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/config\.toml"' +) def run(*args: str, cwd: pathlib.Path, check: bool = True) -> subprocess.CompletedProcess[str]: @@ -65,6 +69,21 @@ def load_workflow(path: pathlib.Path) -> dict[str, Any]: return document +def workflow_steps(document: dict[str, Any]) -> list[tuple[str, int, dict[str, Any]]]: + """Return every (job name, position in job, step) triple of a parsed workflow. + + Acceptance checks assert on parsed structure so that reformatting a + workflow cannot pass or fail a control it does not change. + """ + return [ + (name, index, step) + for name, job in (document.get("jobs") or {}).items() + if isinstance(job, dict) + for index, step in enumerate(job.get("steps") or []) + if isinstance(step, dict) + ] + + def exact_head(repo: pathlib.Path) -> str: return run("git", "rev-parse", "HEAD", cwd=repo).stdout.strip() @@ -364,12 +383,39 @@ def a06(self, directory: pathlib.Path) -> list[str]: repeated = retry_decision({**event, "lastRejectionRepeated": True}, "fp", 2) assert_true(first["recallAgent"], "bounded repair was not allowed") assert_true(not exhausted["recallAgent"] and not repeated["recallAgent"], "exhausted identical failure recalled agent") - php_workflow = (PHP_ROOT / ".github/workflows/autorelease-implement.yml").read_text() - mise_workflow = (self.mise_root / ".github/workflows/autorelease-consumer.yml").read_text() - for name, workflow in {"php-bin": php_workflow, "mise-php": mise_workflow}.items(): - assert_true("authoritative-checks.log" in workflow, f"{name} does not retain deterministic failure logs") - assert_true("Run one offline Codex repair" in workflow, f"{name} has no bounded repair invocation") - assert_true("validate-repair:" in workflow, f"{name} does not cleanly validate repaired bytes") + workflows = { + "php-bin": PHP_ROOT / ".github/workflows/autorelease-implement.yml", + "mise-php": self.mise_root / ".github/workflows/autorelease-consumer.yml", + } + for name, path in workflows.items(): + document = load_workflow(path) + steps = workflow_steps(document) + assert_true( + any("authoritative-checks.log" in (step.get("run") or "") for _, _, step in steps), + f"{name} does not retain deterministic failure logs", + ) + repair_agents = [ + step + for job_name, _, step in steps + if job_name == "repair" and str(step.get("uses") or "").startswith(CODEX_ACTION) + ] + assert_true( + len(repair_agents) == 1, + f"{name} does not bound the repair phase to one agent invocation", + ) + validation = document.get("jobs", {}).get("validate-repair", {}) + assert_true( + "repair" in (validation.get("needs") or []), + f"{name} does not validate repaired bytes in a job that follows the repair", + ) + assert_true( + any( + "sealed-repair" in (step.get("run") or "") + and "./scripts/test.sh" in (step.get("run") or "") + for step in validation.get("steps") or [] + ), + f"{name} does not cleanly validate repaired bytes", + ) assert_true( 'network_access = false' in (PHP_ROOT / ".codex/repair.config.toml").read_text() and 'network_access = false' in (self.mise_root / ".codex/repair.config.toml").read_text(), @@ -386,20 +432,40 @@ def a06(self, directory: pathlib.Path) -> list[str]: return ["retry.json"] def a07(self, directory: pathlib.Path) -> list[str]: - watch = (PHP_ROOT / ".github/workflows/autorelease-watch.yml").read_text() - implementation = (PHP_ROOT / ".github/workflows/autorelease-implement.yml").read_text() - assert_true( - "sandbox: read-only" in watch - and 'cp .codex/investigation.config.toml "$RUNNER_TEMP/codex-home/config.toml"' in watch - and '"--profile"' not in watch, - "investigation sandbox or canonical config loading is missing", - ) + # Every reviewed agent invocation, keyed by the workflow and job that may + # start it, with the sandbox that bounds its network and write authority. + reviewed_sandboxes = { + ("autorelease-watch.yml", "investigate"): "read-only", + ("autorelease-implement.yml", "implement"): "workspace-write", + ("autorelease-implement.yml", "repair"): "workspace-write", + } + observed_sandboxes = {} + for name in ("autorelease-watch.yml", "autorelease-implement.yml"): + steps = workflow_steps(load_workflow(PHP_ROOT / ".github/workflows" / name)) + for job_name, index, step in steps: + if not str(step.get("uses") or "").startswith(CODEX_ACTION): + continue + inputs = step.get("with") or {} + observed_sandboxes[(name, job_name)] = inputs.get("sandbox") + assert_true( + not any( + item.startswith("--profile") + for item in json.loads(inputs.get("codex-args") or "[]") + ), + f"{name}:{job_name} selects a named profile instead of the canonical config", + ) + assert_true( + any( + other_job == job_name + and other_index < index + and CANONICAL_CODEX_CONFIG.search(other.get("run") or "") + for other_job, other_index, other in steps + ), + f"{name}:{job_name} starts the agent without loading its canonical config", + ) assert_true( - "sandbox: workspace-write" in implementation - and 'cp ".codex/$phase.config.toml" "$RUNNER_TEMP/codex-home/config.toml"' in implementation - and 'cp .codex/repair.config.toml "$RUNNER_TEMP/codex-home/config.toml"' in implementation - and '"--profile"' not in implementation, - "phase-bound implementation/repair canonical config loading is missing", + observed_sandboxes == reviewed_sandboxes, + "investigation and implementation agents are not bound to their reviewed sandboxes", ) assert_true('network_access = false' in (PHP_ROOT / ".codex/implementation.config.toml").read_text(), "implementation network is not disabled") assert_true('allowed_domains = ["php.net", "github.com", "docs.github.com"]' in (PHP_ROOT / ".codex/investigation.config.toml").read_text(), "investigation allowlist changed") @@ -469,7 +535,16 @@ def a10(self, directory: pathlib.Path) -> list[str]: releases = (self.mise_root / "lib/releases.lua").read_text() available = (self.mise_root / "hooks/available.lua").read_text() install = (self.mise_root / "hooks/pre_install.lua").read_text() - assert_true("M.is_supported_version" in releases and "8%.[2-5]" in releases, "active shorthand boundary missing") + policy = (self.mise_root / "lib/policy.lua").read_text() + maintained = json.loads((self.mise_root / "support-snapshot.json").read_text())["maintainedBranches"] + assert_true( + "M.is_supported_version" in releases and "policy.maintained" in releases, + "active shorthand boundary is not derived from the maintained policy", + ) + assert_true( + re.findall(r'"(\d+\.\d+)"', policy) == maintained, + "the plugin maintained branch set is not the reviewed support snapshot", + ) assert_true("is_supported_version" in available, "EOL versions can be discovered") assert_true("is_exact_stable_version" in install, "historical exact installation is blocked") (directory / "eol-policy.txt").write_text("discovery=maintained-only\ninstallation=exact-stable-history\npublication=maintained-only\n") @@ -537,20 +612,33 @@ def a13(self, directory: pathlib.Path) -> list[str]: "Codex Action pin is not bound to the reviewed input contract", ) e2e = PHP_ROOT / ".github/workflows/autorelease-e2e.yml" - e2e_text = e2e.read_text() + canary_schema_steps = [ + step + for job_name, _, step in workflow_steps(load_workflow(e2e)) + if job_name == "agent-canary" and "canary/schema.json" in (step.get("run") or "") + ] + assert_true( + len(canary_schema_steps) == 1, + "the credentialed agent canary does not build its output schema in one step", + ) + # The canary schema is generated, so the generator is rendered here and + # the resulting schema is asserted instead of its source formatting. + program = re.search(r"'([^']+)'\s*>\s*canary/schema\.json", canary_schema_steps[0]["run"]) + assert_true(program is not None, "the credentialed agent canary schema is not built by one jq program") + canary_schema = json.loads( + run("jq", "-n", "--arg", "nonce", "fixture-nonce", program.group(1), cwd=PHP_ROOT).stdout + ) assert_true( - 'status:{type:"string",const:"passed"}' in e2e_text - and 'nonce:{type:"string",const:$nonce}' in e2e_text, - "credentialed agent canary schema does not declare string types", + canary_schema.get("additionalProperties") is False + and canary_schema.get("properties", {}).get("status") == {"type": "string", "const": "passed"} + and canary_schema.get("properties", {}).get("nonce") == {"type": "string", "const": "fixture-nonce"}, + "credentialed agent canary schema does not bind status and nonce to exact strings", ) assert_true( pins["workflows"][".github/workflows/autorelease-e2e.yml"] == sha256_file(e2e), "reviewed production-parity workflow digest changed", ) - watch_path = PHP_ROOT / ".github/workflows/autorelease-watch.yml" - watch = watch_path.read_text() - release = (PHP_ROOT / ".github/workflows/autorelease-publish.yml").read_text() - watch_document = load_workflow(watch_path) + watch_document = load_workflow(PHP_ROOT / ".github/workflows/autorelease-watch.yml") workflow_permissions = watch_document.get("permissions", {}) investigate = watch_document.get("jobs", {}).get("investigate", {}) investigate_permissions = investigate.get("permissions", workflow_permissions) @@ -559,7 +647,18 @@ def a13(self, directory: pathlib.Path) -> list[str]: and investigate_permissions.get("contents") == "read", "runtime investigation does not have resolved read-only contents permission", ) - assert_true("openai-api-key" not in release, "release job can read OpenAI credential") + credentialed_release_steps = [ + f"{job_name}:step-{index + 1}" + for job_name, index, step in workflow_steps( + load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml") + ) + if "openai-api-key" in (step.get("with") or {}) + or any("OPENAI_API_KEY" in str(value) for value in (step.get("env") or {}).values()) + ] + assert_true( + not credentialed_release_steps, + f"release transaction steps can read the OpenAI credential: {credentialed_release_steps}", + ) admin = PHP_ROOT / "docs/autorelease-admin-evidence.json" assert_true(admin.is_file(), "redacted administrator evidence is missing") evidence = json.loads(admin.read_text()) @@ -623,19 +722,38 @@ def a17(self, directory: pathlib.Path) -> list[str]: def a18(self, directory: pathlib.Path) -> list[str]: assert_true(not mutation_allowed({"unattendedMutation": "paused"}), "paused control allowed mutation") assert_true(mutation_allowed({"unattendedMutation": "enabled"}), "enabled control blocked mutation") - watch_workflow = (PHP_ROOT / ".github/workflows/autorelease-watch.yml").read_text() - release_workflow = (PHP_ROOT / ".github/workflows/autorelease-publish.yml").read_text() - mise_workflow = (self.mise_root / ".github/workflows/autorelease-consumer.yml").read_text() + watch_steps = workflow_steps(load_workflow(PHP_ROOT / ".github/workflows/autorelease-watch.yml")) + dispatch_steps = [step for _, _, step in watch_steps if "gh workflow run" in (step.get("run") or "")] + assert_true(dispatch_steps, "watcher no longer dispatches downstream mutation") assert_true( - "Unattended mutation is paused" in watch_workflow, + all("unattendedMutation" in step["run"] for step in dispatch_steps), "watcher pause does not stop downstream mutation", ) + release_steps = workflow_steps(load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml")) + effect_steps = [ + (job_name, step) + for job_name, _, step in release_steps + if "./scripts/publish-release" in (step.get("run") or "") + ] + assert_true(effect_steps, "release workflow performs no release transition") assert_true( - release_workflow.count("current-operator.json") >= 3, + all( + job_name == "release" + and "current-operator.json" in step["run"] + and "unattendedMutation" in step["run"] + for job_name, step in effect_steps + ), "release effects are not gated by the live operator state", ) + mise_steps = workflow_steps(load_workflow(self.mise_root / ".github/workflows/autorelease-consumer.yml")) + operator_bound_jobs = { + job_name + for job_name, _, step in mise_steps + if "phpBinOperatorCommit" in (step.get("run") or "") + and "operatorState" in (step.get("run") or "") + } assert_true( - "phpBinOperatorCommit" in mise_workflow and "operatorState" in mise_workflow, + {"investigate", "merge-and-record-readiness"} <= operator_bound_jobs, "mise synchronization is not bound to the php-bin operator control", ) event = {"actionKey": "new_patch:8.5.9", "state": "release_requested", "history": []} 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, From b907f5dbec0e7b156938e4089e07fac78adf51a1 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 14:25:59 +0300 Subject: [PATCH 09/48] fix: assert the whole release workflow is free of the OpenAI credential --- autorelease/verify.py | 44 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/autorelease/verify.py b/autorelease/verify.py index 94f2569..f88de3e 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -84,6 +84,30 @@ def workflow_steps(document: dict[str, Any]) -> list[tuple[str, int, dict[str, A ] +def credential_sites(node: Any, path: str) -> list[str]: + """Return every path in a parsed workflow whose keys or values name the OpenAI credential. + + Both spellings reach an agent: `openai-api-key` as an action input and + `OPENAI_API_KEY` as an environment name. Either can be attached at the + workflow, job, or step level, or interpolated straight into a command, so + the whole parsed document is walked rather than one level of it. + """ + if isinstance(node, dict): + return [ + site + for key, value in node.items() + for site in ([f"{path}.{key}"] if names_credential(str(key)) else []) + + credential_sites(value, f"{path}.{key}") + ] + if isinstance(node, list): + return [site for index, item in enumerate(node) for site in credential_sites(item, f"{path}[{index}]")] + return [path] if names_credential(str(node)) else [] + + +def names_credential(text: str) -> bool: + return "openai-api-key" in text.lower() or "openai_api_key" in text.lower() + + def exact_head(repo: pathlib.Path) -> str: return run("git", "rev-parse", "HEAD", cwd=repo).stdout.strip() @@ -647,17 +671,19 @@ def a13(self, directory: pathlib.Path) -> list[str]: and investigate_permissions.get("contents") == "read", "runtime investigation does not have resolved read-only contents permission", ) - credentialed_release_steps = [ - f"{job_name}:step-{index + 1}" - for job_name, index, step in workflow_steps( - load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml") + # The release transaction runs no agent, so no part of it may carry the + # credential: not a workflow, job, or step environment, not an input, + # and not an interpolation inside a command body. + release_credential_sites = sorted( + set( + credential_sites( + load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml"), "autorelease-publish" + ) ) - if "openai-api-key" in (step.get("with") or {}) - or any("OPENAI_API_KEY" in str(value) for value in (step.get("env") or {}).values()) - ] + ) assert_true( - not credentialed_release_steps, - f"release transaction steps can read the OpenAI credential: {credentialed_release_steps}", + not release_credential_sites, + f"the release transaction can read the OpenAI credential: {release_credential_sites}", ) admin = PHP_ROOT / "docs/autorelease-admin-evidence.json" assert_true(admin.is_file(), "redacted administrator evidence is missing") From 7c9c0c07c2cc17d05170b0e968e5b3137f8e6522 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 14:59:45 +0300 Subject: [PATCH 10/48] feat: decide to recover a published release with no event record --- autorelease/control.py | 43 ++++++++++++++++++++- scripts/watch-autorelease-evidence | 14 +++++++ tests/test_autorelease.py | 62 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/autorelease/control.py b/autorelease/control.py index b726724..73d0661 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -55,6 +55,9 @@ } RUNTIME_PLAN_EVIDENCE_IDS = {"evidence_manifest", "watch_decision"} STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") +# A zero patch component is deliberately excluded: `8.6.0` is equally the tag of a +# `new_branch:8.6` action, so its action key is not derivable from the tag alone. +RECOVERABLE_RELEASE_TAG_RE = re.compile(r"^(\d+\.\d+\.[1-9]\d*)(?:-([1-9]\d*))?$") PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") try: PROTECTED_PATTERNS = tuple(json.loads(PROTECTED_PATHS.read_text())["patterns"]) @@ -961,6 +964,35 @@ def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any] return None +def unrecorded_published_release( + releases: Iterable[dict[str, Any]], + events: Iterable[dict[str, Any]], +) -> str | None: + """Return the action key of one published release that has no event record at all. + + A live release with no record silently corrupts every later decision, because the + completed-action ledger is what admission uses to tell finished work from new work. + Recovery is fail-closed: a release is only claimed when immutability proves it came + from the guarded publish transaction and its action key is derivable from the tag + alone. Any existing record, complete or not, is left to its own path. One key is + returned per run; a further backlog is repaired by later runs. + """ + recorded = {event.get("actionKey") for event in events} + keys = set() + for release in releases: + if not isinstance(release, dict): + continue + if release.get("draft") or release.get("prerelease") or release.get("immutable") is not True: + continue + tag = RECOVERABLE_RELEASE_TAG_RE.fullmatch(str(release.get("tag_name", ""))) + if tag is None: + continue + key = f"recipe_rebuild:{tag.group(1)}:{tag.group(2)}" if tag.group(2) else f"new_patch:{tag.group(1)}" + if key not in recorded: + keys.add(key) + return min(keys, default=None) + + def watch_decision( manifest: dict[str, Any], previous: dict[str, Any], @@ -968,16 +1000,23 @@ def watch_decision( health: dict[str, Any], *, self_evidence_update: bool = False, + releases: Iterable[dict[str, Any]] = (), ) -> dict[str, Any]: + events = list(events) incomplete = sorted( event.get("actionKey") for event in events if event.get("state") != "complete" ) + # Ranked above every reconciliation and selection trigger, and below the two health + # triggers that decide whether the release evidence can be trusted at all. + unrecorded = unrecorded_published_release(releases, events) if not health.get("healthy", False): trigger = "health_failed" elif any(capture.get("status") != 200 for capture in manifest.get("captures", [])): trigger = "source_unhealthy" + elif unrecorded: + trigger = "record_missing" elif incomplete: trigger = "event_incomplete" elif previous.get("manifestDigest") != manifest.get("manifestDigest"): @@ -1008,7 +1047,9 @@ def watch_decision( "trigger": trigger, "manifestDigest": manifest.get("manifestDigest"), "incompleteActions": incomplete, - "modelCall": trigger != "quiet", + "action": "record_completed_event" if trigger == "record_missing" else "none", + "actionKey": unrecorded if trigger == "record_missing" else "", + "modelCall": trigger not in {"quiet", "record_missing"}, } diff --git a/scripts/watch-autorelease-evidence b/scripts/watch-autorelease-evidence index 9791c70..3506361 100755 --- a/scripts/watch-autorelease-evidence +++ b/scripts/watch-autorelease-evidence @@ -9,7 +9,9 @@ import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from autorelease.control import ( # noqa: E402 ControlError, + load_capture, load_json, + require, validate_evidence_state_record, watch_decision, write_json, @@ -51,12 +53,24 @@ try: raise ControlError("previous evidence state is missing and event reconstruction is ambiguous") if matching: previous["manifestDigest"] = matching[0] + # Only a healthy capture can prove a release; an unhealthy one already decides the + # run through the source_unhealthy trigger, so its body is never parsed. + releases: list[dict] = [] + capture, body = load_capture(args.manifest, "php_bin_releases") + if capture.get("status") == 200: + try: + published = json.loads(body) + except json.JSONDecodeError as error: + raise ControlError(f"captured php-bin releases are not valid JSON: {error}") from error + require(isinstance(published, list), "captured php-bin releases are not an array") + releases = [item for item in published if isinstance(item, dict)] decision = watch_decision( manifest, previous, events, health, self_evidence_update=args.self_evidence_update, + releases=releases, ) write_json(args.output, decision) print(json.dumps(decision)) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 98bc182..ed2f94f 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -106,6 +106,68 @@ def test_evidence_recording_commit_does_not_wake_itself(self): ) self.assertEqual("evidence_changed", external_change["trigger"]) + @staticmethod + def _releases_manifest(status=200): + return { + "manifestDigest": "sha256:" + "a" * 64, + "captures": [{"captureId": "php_bin_releases", "status": status, "digest": "sha256:" + "b" * 64}], + } + + def test_watch_flags_published_release_missing_event_record(self): + manifest = self._releases_manifest() + releases = [ + {"tag_name": "8.5.9", "draft": False, "prerelease": False, "immutable": True}, + {"tag_name": "8.5.8", "draft": False, "prerelease": False, "immutable": True}, + ] + events = [{"actionKey": "new_patch:8.5.8", "state": "complete"}] + decision = watch_decision(manifest, manifest, events, {"healthy": True}, releases=releases) + self.assertEqual("record_completed_event", decision["action"]) + self.assertEqual("new_patch:8.5.9", decision["actionKey"]) + self.assertEqual("record_missing", decision["trigger"]) + self.assertFalse(decision["modelCall"]) + + # A changed snapshot would otherwise select new work; the missing record wins. + changed = {"manifestDigest": "sha256:" + "c" * 64, "captures": manifest["captures"]} + moved = watch_decision(changed, manifest, events, {"healthy": True}, releases=releases) + self.assertEqual("record_completed_event", moved["action"]) + + rebuild = watch_decision( + manifest, + manifest, + [*events, {"actionKey": "new_patch:8.5.9", "state": "complete"}], + {"healthy": True}, + releases=[*releases, {"tag_name": "8.5.9-2", "draft": False, "prerelease": False, "immutable": True}], + ) + self.assertEqual("recipe_rebuild:8.5.9:2", rebuild["actionKey"]) + + def test_unprovable_release_records_are_not_recovered(self): + manifest = self._releases_manifest() + published = {"tag_name": "8.5.9", "draft": False, "prerelease": False, "immutable": True} + for release in ( + {**published, "immutable": False}, + {**published, "draft": True}, + {**published, "prerelease": True}, + {**published, "tag_name": "8.6.0"}, + {**published, "tag_name": "8.5.9-rc1"}, + ): + decision = watch_decision(manifest, manifest, [], {"healthy": True}, releases=[release]) + self.assertEqual("none", decision["action"], release) + self.assertEqual("quiet", decision["trigger"], release) + unhealthy = self._releases_manifest(status=500) + self.assertEqual( + "source_unhealthy", + watch_decision(unhealthy, unhealthy, [], {"healthy": True}, releases=[published])["trigger"], + ) + for state in ("complete", "released"): + decision = watch_decision( + manifest, + manifest, + [{"actionKey": "new_patch:8.5.9", "state": state}], + {"healthy": True}, + releases=[published], + ) + self.assertEqual("none", decision["action"], state) + def test_completion_go_is_mechanical(self): contract = { "contractVersion": 1, From d174ea89356878d2fa69ad3bc99d632b25708e9b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 15:00:07 +0300 Subject: [PATCH 11/48] feat: recover missing event records from the watcher --- .github/workflows/autorelease-publish.yml | 55 +++++++++- .github/workflows/autorelease-watch.yml | 116 ++++++++++++++++++++-- tests/test_autorelease.py | 17 ++++ 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/.github/workflows/autorelease-publish.yml b/.github/workflows/autorelease-publish.yml index 2c50f38..f6dbbe2 100644 --- a/.github/workflows/autorelease-publish.yml +++ b/.github/workflows/autorelease-publish.yml @@ -289,6 +289,26 @@ jobs: sleep 5 done test "$verified" = "true" + - name: Record whether the immutable release is live + if: always() + run: | + mkdir -p release-run + released=false + if [[ -f release-run/transaction.json ]]; then + case "$(jq -r .state release-run/transaction.json)" in + published|public_verified|complete) released=true ;; + esac + fi + jq -n --argjson released "$released" --arg version "$VERSION" \ + '{schemaVersion:1,released:$released,version:$version}' > release-run/transaction-state.json + - name: Retain the transaction state for triage + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-transaction-state-${{ github.run_id }} + path: release-run/transaction-state.json + if-no-files-found: error + retention-days: 90 - name: Verify fresh public exact-version and branch-shorthand installs run: | unset MISE_PHP_API_BASE_URL @@ -413,23 +433,50 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: + actions: read contents: read issues: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - name: Create structured critical event + # A run that fails before the state is retained keeps the critical default below. + - name: Download the retained transaction state + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-transaction-state-${{ github.run_id }} + path: release-state + - name: Create a structured event keyed on the transaction state env: ACTION_KEY: ${{ needs.preflight.outputs.action_key }} VERSION: ${{ needs.preflight.outputs.version }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | + # A live release is not a critical failure: the watcher files the missing + # event record on its own. Only an unpublished release stops the pipeline. + released=false + if [[ -f release-state/transaction-state.json ]]; then + released="$(jq -r .released release-state/transaction-state.json)" + fi + if [[ "$released" == "true" ]]; then + state=released + severity=warning + summary="PHP $VERSION was published; its event record is pending and the watcher will recover it: $RUN_URL" + fingerprint="release-record-pending:$ACTION_KEY" + else + state=blocked + severity=critical + summary="Autorelease failed for PHP $VERSION: $RUN_URL" + fingerprint="release-failure:$ACTION_KEY" + fi jq -n \ --arg actionKey "$ACTION_KEY" \ - --arg summary "Autorelease failed for PHP $VERSION: $RUN_URL" \ - --arg failureFingerprint "release-failure:$ACTION_KEY" \ - '{actionKey:$actionKey,state:"blocked",severity:"critical",humanActionRequired:false,summary:$summary,failureFingerprint:$failureFingerprint}' \ + --arg state "$state" \ + --arg severity "$severity" \ + --arg summary "$summary" \ + --arg failureFingerprint "$fingerprint" \ + '{actionKey:$actionKey,state:$state,severity:$severity,humanActionRequired:false,summary:$summary,failureFingerprint:$failureFingerprint}' \ > event.json - name: Notify owner env: diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 4af3a1a..4a0e6b7 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -19,10 +19,12 @@ jobs: timeout-minutes: 30 outputs: trigger: ${{ steps.decision.outputs.trigger }} - action_key: ${{ steps.plan.outputs.action_key }} + # A deterministic decision carries its own action and key because it never calls + # the model, so the plan step that usually publishes them is skipped. + action_key: ${{ steps.plan.outputs.action_key || steps.decision.outputs.action_key }} edits_required: ${{ steps.plan.outputs.edits_required }} base_sha: ${{ steps.plan.outputs.base_sha }} - action: ${{ steps.plan.outputs.action }} + action: ${{ steps.plan.outputs.action || steps.decision.outputs.action }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -47,9 +49,14 @@ jobs: --events autorelease-events \ "${self_update[@]}" \ --output autorelease-run/watch-decision.json - echo "trigger=$(jq -r .trigger autorelease-run/watch-decision.json)" >> "$GITHUB_OUTPUT" + { + echo "trigger=$(jq -r .trigger autorelease-run/watch-decision.json)" + echo "model_call=$(jq -r .modelCall autorelease-run/watch-decision.json)" + echo "action=$(jq -r .action autorelease-run/watch-decision.json)" + echo "action_key=$(jq -r .actionKey autorelease-run/watch-decision.json)" + } >> "$GITHUB_OUTPUT" - name: Record exact preconditions - if: steps.decision.outputs.trigger != 'quiet' + if: steps.decision.outputs.model_call == 'true' run: | jq -n \ --arg phpBinHead "$(git rev-parse HEAD)" \ @@ -67,7 +74,7 @@ jobs: printf '[]\n' > autorelease-run/completed-actions.json fi - name: Prepare investigation contract - if: steps.decision.outputs.trigger != 'quiet' + if: steps.decision.outputs.model_call == 'true' run: | ./scripts/prepare-agent-task \ --phase investigation \ @@ -90,7 +97,7 @@ jobs: mkdir -p "$RUNNER_TEMP/codex-home" cp .codex/investigation.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run read-only Codex investigation - if: steps.decision.outputs.trigger != 'quiet' + if: steps.decision.outputs.model_call == 'true' uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -105,7 +112,7 @@ jobs: allow-bot-users: github-actions[bot] codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/autorelease-plan.schema.json"]' - name: Admit plan against exact evidence - if: steps.decision.outputs.trigger != 'quiet' + if: steps.decision.outputs.model_call == 'true' run: | ./scripts/admit-autorelease-plan \ --plan autorelease-run/autorelease-plan.json \ @@ -121,7 +128,7 @@ jobs: --output autorelease-run/admission.json - name: Expose admitted plan id: plan - if: steps.decision.outputs.trigger != 'quiet' + if: steps.decision.outputs.model_call == 'true' run: | echo "action_key=$(jq -r .actionKey autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" echo "edits_required=$(jq -r .editsRequired autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" @@ -283,6 +290,99 @@ jobs: test "sha256:$(git show "$head:autorelease-state/last-evidence.json" | shasum -a 256 | awk '{print $1}')" = "$record_digest" gh pr merge "$number" --squash --delete-branch exit 0 + elif [[ "$action" == "record_completed_event" ]]; then + action_key="${{ needs.investigate.outputs.action_key }}" + # new_patch:8.5.9 is tag 8.5.9 and recipe_rebuild:8.5.9:2 is tag 8.5.9-2. + version="${action_key#*:}" + version="${version/:/-}" + classification="${action_key%%:*}" + filename="$(printf '%s' "$action_key" | tr ':/' '--').json" + event="autorelease-events/$filename" + test ! -f "$event" + test "$(gh release view "$version" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)" = "false" + test "$(gh release view "$version" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = "true" + assets=autorelease-plan-download/recovered-release + mkdir -p "$assets" + gh release download "$version" --repo "${{ github.repository }}" --dir "$assets" --clobber + ./scripts/validate-autorelease-archive \ + --archive "$assets/php-$version-cli-macos-aarch64.tar.gz" \ + --version "$version" + archive_digest="$(shasum -a 256 "$assets/php-$version-cli-macos-aarch64.tar.gz" | awk '{print $1}')" + grep -Fx "$archive_digest php-$version-cli-macos-aarch64.tar.gz" "$assets/SHA256SUMS" + checksums_digest="$(shasum -a 256 "$assets/SHA256SUMS" | awk '{print $1}')" + release_commit="$(gh api "repos/${{ github.repository }}/commits/$version" --jq .sha)" + [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] + # The record states exactly what this run verified: the public release bytes. + # A fresh install is not reverifiable here, so it is never claimed as evidence. + jq -n \ + --arg actionKey "$action_key" \ + --arg classification "$classification" \ + --arg commit "$release_commit" \ + --arg runId "${{ github.run_id }}" \ + --arg evidenceManifestDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ + '{schemaVersion:1,actionKey:$actionKey,classification:$classification,state:"release_requested",history:[],phpBinCommit:$commit,evidenceManifestDigest:$evidenceManifestDigest,recoveredByRunId:$runId}' \ + > autorelease-plan-download/recovered-event.json + jq -n \ + --arg version "$version" \ + --arg archive "sha256:$archive_digest" \ + --arg checksums "sha256:$checksums_digest" \ + '[{kind:"published_immutable_release",version:$version,assetDigests:{("php-"+$version+"-cli-macos-aarch64.tar.gz"):$archive,"SHA256SUMS":$checksums}}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target released \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + jq -n --arg version "$version" \ + '[{kind:"public_release_bytes_reverified",version:$version,modes:["public_download"]}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json \ + --target public_install_verified \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + jq -n --arg runId "${{ github.run_id }}" '[{kind:"record_recovered_by_watcher",runId:$runId}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target complete \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + base="$(git rev-parse HEAD)" + # The protected-controls exemption trusts this branch prefix from this + # workflow; a recovered record is filed exactly like an EOL completion. + branch="autorelease/eol-complete-${{ github.run_id }}" + git checkout -B "$branch" + cp autorelease-plan-download/recovered-event.json "$event" + git add "$event" + git -c user.name=autorelease-watcher -c user.email=autorelease@invalid \ + commit -m "chore: complete $action_key" + head="$(git rev-parse HEAD)" + digest="sha256:$(shasum -a 256 "$event" | awk '{print $1}')" + gh auth setup-git + git push origin HEAD + url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \ + --body "Recovered durable event record for an immutable published release.")" + number="${url##*/}" + ./scripts/dispatch-pr-checks \ + --pr "$number" \ + --check "Script checks" \ + --output autorelease-plan-download/recovery-checks.json + ./scripts/assert-admission-checks --require-protected-controls --checks autorelease-plan-download/recovery-checks.json + test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" + git fetch origin main + test "$(git rev-parse origin/main)" = "$base" + test "$(git rev-list --parents -n 1 "$head")" = "$head $base" + test "$(git diff --name-only "$base" "$head")" = "$event" + test "sha256:$(git show "$head:$event" | shasum -a 256 | awk '{print $1}')" = "$digest" + gh pr merge "$number" --squash --delete-branch + jq --arg version "$version" \ + '.severity="info" | .summary="The event record for the published PHP \($version) release was recovered by the watcher." | .finalResult="passed"' \ + "$event" > autorelease-plan-download/recovery-notification.json + ./scripts/notify-autorelease \ + --event autorelease-plan-download/recovery-notification.json \ + --state autorelease-plan-download/recovery-notification-state.json \ + --output autorelease-plan-download/recovery-notification-next.json \ + --backend github --repo "${{ github.repository }}" --owner "${{ vars.AUTORELEASE_OWNER }}" + exit 0 elif [[ "${{ needs.investigate.outputs.edits_required }}" == "true" ]]; then gh workflow run autorelease-implement.yml \ --repo "${{ github.repository }}" \ diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index ed2f94f..e023a05 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -530,6 +530,23 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): release.index("Notify owner of completed release"), ) + def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): + root = pathlib.Path(__file__).resolve().parents[1] + watcher = (root / ".github/workflows/autorelease-watch.yml").read_text() + release = (root / ".github/workflows/autorelease-publish.yml").read_text() + protected = (root / ".github/workflows/protected-controls.yml").read_text() + recovery = watcher[watcher.index('elif [[ "$action" == "record_completed_event" ]]'):] + # The exemption only trusts this prefix from this workflow on these events. + self.assertIn('branch="autorelease/eol-complete-${{ github.run_id }}"', recovery) + self.assertIn("--require-protected-controls", recovery) + self.assertIn('".github/workflows/autorelease-watch.yml"', protected) + self.assertIn('{"schedule", "workflow_dispatch"}', protected) + self.assertIn("schedule:", watcher) + self.assertIn("workflow_dispatch:", watcher) + # A published release downgrades the publish alarm from critical to warning. + self.assertIn("release-transaction-state-${{ github.run_id }}", release) + self.assertIn("jq -r .released release-state/transaction-state.json", release) + def test_assert_admission_checks(self): script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/assert-admission-checks") ok = [{"name": "Script checks", "bucket": "pass"}, From 0f641aae95b4dda869094173d94ae28e1620a405 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 15:26:18 +0300 Subject: [PATCH 12/48] fix: stop a blocked record recovery from starving the watcher Recovery is a pure function of the release list and the event store, with no bound, so suppressing the model call let a repair that stays blocked starve reconciliation, lifecycle and selection on every later run. The trigger label still reports the missing record, but modelCall keeps the value the rest of the chain produced. A record filename already occupied by an unrelated document is declined outright: the filer refuses to overwrite a file, so the decision would otherwise request the same failing repair forever. --- autorelease/control.py | 31 ++++++++++++++++------- scripts/watch-autorelease-evidence | 3 +++ tests/test_autorelease.py | 40 ++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/autorelease/control.py b/autorelease/control.py index 73d0661..0fcbd30 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -964,9 +964,15 @@ def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any] return None +def event_record_filename(action_key: str) -> str: + """Return the single record filename an action key may occupy.""" + return action_key.translate(str.maketrans({":": "-", "/": "-"})) + ".json" + + def unrecorded_published_release( releases: Iterable[dict[str, Any]], events: Iterable[dict[str, Any]], + record_files: Iterable[str] = (), ) -> str | None: """Return the action key of one published release that has no event record at all. @@ -974,10 +980,13 @@ def unrecorded_published_release( completed-action ledger is what admission uses to tell finished work from new work. Recovery is fail-closed: a release is only claimed when immutability proves it came from the guarded publish transaction and its action key is derivable from the tag - alone. Any existing record, complete or not, is left to its own path. One key is - returned per run; a further backlog is repaired by later runs. + alone. Any existing record, complete or not, is left to its own path, and so is a + key whose record filename is already occupied by an unrelated document, because the + filer refuses to overwrite a file and would otherwise fail on every later run. One + key is returned per run; a further backlog is repaired by later runs. """ recorded = {event.get("actionKey") for event in events} + occupied = set(record_files) keys = set() for release in releases: if not isinstance(release, dict): @@ -988,7 +997,7 @@ def unrecorded_published_release( if tag is None: continue key = f"recipe_rebuild:{tag.group(1)}:{tag.group(2)}" if tag.group(2) else f"new_patch:{tag.group(1)}" - if key not in recorded: + if key not in recorded and event_record_filename(key) not in occupied: keys.add(key) return min(keys, default=None) @@ -1001,6 +1010,7 @@ def watch_decision( *, self_evidence_update: bool = False, releases: Iterable[dict[str, Any]] = (), + record_files: Iterable[str] = (), ) -> dict[str, Any]: events = list(events) incomplete = sorted( @@ -1008,15 +1018,11 @@ def watch_decision( for event in events if event.get("state") != "complete" ) - # Ranked above every reconciliation and selection trigger, and below the two health - # triggers that decide whether the release evidence can be trusted at all. - unrecorded = unrecorded_published_release(releases, events) + unrecorded = unrecorded_published_release(releases, events, record_files) if not health.get("healthy", False): trigger = "health_failed" elif any(capture.get("status") != 200 for capture in manifest.get("captures", [])): trigger = "source_unhealthy" - elif unrecorded: - trigger = "record_missing" elif incomplete: trigger = "event_incomplete" elif previous.get("manifestDigest") != manifest.get("manifestDigest"): @@ -1042,6 +1048,13 @@ def watch_decision( ) else: trigger = "quiet" + # A missing record outranks every trigger that a trustworthy snapshot can raise, so + # it is repaired before new work starts. It never changes whether the model is + # called: the repair is deterministic, but suppressing the investigation would let a + # blocked repair starve reconciliation and selection on every later run. + model_call = trigger != "quiet" + if unrecorded and trigger not in {"health_failed", "source_unhealthy"}: + trigger = "record_missing" return { "schemaVersion": 1, "trigger": trigger, @@ -1049,7 +1062,7 @@ def watch_decision( "incompleteActions": incomplete, "action": "record_completed_event" if trigger == "record_missing" else "none", "actionKey": unrecorded if trigger == "record_missing" else "", - "modelCall": trigger not in {"quiet", "record_missing"}, + "modelCall": model_call, } diff --git a/scripts/watch-autorelease-evidence b/scripts/watch-autorelease-evidence index 3506361..3e9de95 100755 --- a/scripts/watch-autorelease-evidence +++ b/scripts/watch-autorelease-evidence @@ -41,8 +41,10 @@ try: if not args.events.is_dir(): raise ControlError(f"supplied events directory is missing or invalid: {args.events}") events = [] + record_files = [] for path in sorted(args.events.glob("*.json")): events.append(load_json(path)) + record_files.append(path.name) if not previous.get("manifestDigest"): matching = sorted({ event.get("evidenceManifestDigest") @@ -71,6 +73,7 @@ try: health, self_evidence_update=args.self_evidence_update, releases=releases, + record_files=record_files, ) write_json(args.output, decision) print(json.dumps(decision)) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index e023a05..e7761f5 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -126,10 +126,23 @@ def test_watch_flags_published_release_missing_event_record(self): self.assertEqual("record_missing", decision["trigger"]) self.assertFalse(decision["modelCall"]) - # A changed snapshot would otherwise select new work; the missing record wins. + # A changed snapshot would otherwise select new work; the missing record wins the + # trigger, but recovery never withholds the investigation those paths depend on, + # so a repair that stays blocked cannot starve them run after run. changed = {"manifestDigest": "sha256:" + "c" * 64, "captures": manifest["captures"]} moved = watch_decision(changed, manifest, events, {"healthy": True}, releases=releases) self.assertEqual("record_completed_event", moved["action"]) + self.assertTrue(moved["modelCall"]) + incomplete = watch_decision( + manifest, + manifest, + [*events, {"actionKey": "new_patch:8.5.7", "state": "released"}], + {"healthy": True}, + releases=releases, + ) + self.assertEqual("record_completed_event", incomplete["action"]) + self.assertTrue(incomplete["modelCall"]) + self.assertEqual(["new_patch:8.5.7"], incomplete["incompleteActions"]) rebuild = watch_decision( manifest, @@ -167,6 +180,18 @@ def test_unprovable_release_records_are_not_recovered(self): releases=[published], ) self.assertEqual("none", decision["action"], state) + # The filer refuses to overwrite an existing file, so a record filename already + # taken by an unrelated document must not be requested again on every run. + occupied = watch_decision( + manifest, + manifest, + [], + {"healthy": True}, + releases=[published], + record_files=["new_patch-8.5.9.json"], + ) + self.assertEqual("none", occupied["action"]) + self.assertEqual("quiet", occupied["trigger"]) def test_completion_go_is_mechanical(self): contract = { @@ -535,7 +560,8 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): watcher = (root / ".github/workflows/autorelease-watch.yml").read_text() release = (root / ".github/workflows/autorelease-publish.yml").read_text() protected = (root / ".github/workflows/protected-controls.yml").read_text() - recovery = watcher[watcher.index('elif [[ "$action" == "record_completed_event" ]]'):] + start = watcher.index("- name: Recover the event record of a published release") + recovery = watcher[start:watcher.index("- name: Prepare deterministic no-change evidence")] # The exemption only trusts this prefix from this workflow on these events. self.assertIn('branch="autorelease/eol-complete-${{ github.run_id }}"', recovery) self.assertIn("--require-protected-controls", recovery) @@ -543,6 +569,16 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): self.assertIn('{"schedule", "workflow_dispatch"}', protected) self.assertIn("schedule:", watcher) self.assertIn("workflow_dispatch:", watcher) + # Assets and checksums of a hand-made release prove each other and nothing else. + self.assertIn('gh release verify "$version" --repo "${{ github.repository }}" --format json', recovery) + self.assertIn('git merge-base --is-ancestor "$release_commit" origin/main', recovery) + # A failing repair yields to the other paths and is only raised after them. + self.assertIn("continue-on-error: true", recovery) + self.assertLess(start, watcher.index("- name: Dispatch implementation or no-edit release")) + self.assertLess( + watcher.index("- name: Dispatch implementation or no-edit release"), + watcher.index("if: steps.recover.outcome == 'failure'"), + ) # A published release downgrades the publish alarm from critical to warning. self.assertIn("release-transaction-state-${{ github.run_id }}", release) self.assertIn("jq -r .released release-state/transaction-state.json", release) From e78e2f917f5c47e5b95c68835de9ac57f67a26d7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 15:26:25 +0300 Subject: [PATCH 13/48] fix: bind a recovered record to an attested release on main The assets and the SHA256SUMS of a hand-made release prove each other and nothing else, so the route now verifies the release attestation exactly as the publish job does and requires the tagged commit to be reachable from main before any record is filed. Recovery also moves out of the dispatch chain into its own step that is allowed to fail, so a blocked repair no longer withholds the reconciliation, lifecycle and selection paths of the same run; the failure is re-raised after them. --- .github/workflows/autorelease-watch.yml | 253 ++++++++++++++---------- 1 file changed, 153 insertions(+), 100 deletions(-) diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 4a0e6b7..2d4c87c 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -19,12 +19,13 @@ jobs: timeout-minutes: 30 outputs: trigger: ${{ steps.decision.outputs.trigger }} - # A deterministic decision carries its own action and key because it never calls - # the model, so the plan step that usually publishes them is skipped. - action_key: ${{ steps.plan.outputs.action_key || steps.decision.outputs.action_key }} + action_key: ${{ steps.plan.outputs.action_key }} edits_required: ${{ steps.plan.outputs.edits_required }} base_sha: ${{ steps.plan.outputs.base_sha }} - action: ${{ steps.plan.outputs.action || steps.decision.outputs.action }} + action: ${{ steps.plan.outputs.action }} + # Recovery is decided deterministically and runs beside the admitted plan, so it + # carries its own key rather than competing for the plan's. + record_action_key: ${{ steps.decision.outputs.action_key }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -52,7 +53,6 @@ jobs: { echo "trigger=$(jq -r .trigger autorelease-run/watch-decision.json)" echo "model_call=$(jq -r .modelCall autorelease-run/watch-decision.json)" - echo "action=$(jq -r .action autorelease-run/watch-decision.json)" echo "action_key=$(jq -r .actionKey autorelease-run/watch-decision.json)" } >> "$GITHUB_OUTPUT" - name: Record exact preconditions @@ -147,7 +147,7 @@ jobs: coordinate: name: Dispatch admitted next phase needs: investigate - if: needs.investigate.outputs.action_key != '' + if: needs.investigate.outputs.action_key != '' || needs.investigate.outputs.record_action_key != '' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -180,9 +180,125 @@ jobs: test "$(jq -r .unattendedMutation .github/autorelease-operator.json)" = "enabled" \ && echo "enabled=true" >> "$GITHUB_OUTPUT" \ || echo "enabled=false" >> "$GITHUB_OUTPUT" + - name: Recover the event record of a published release + id: recover + if: needs.investigate.outputs.record_action_key != '' + # A blocked or failing repair must never suppress the other watcher paths, so + # the step is allowed to fail here and is re-raised as a job failure after them. + continue-on-error: true + env: + ACTION_KEY: ${{ needs.investigate.outputs.record_action_key }} + GH_TOKEN: ${{ github.token }} + run: | + if [[ "$(jq -r .unattendedMutation .github/autorelease-operator.json)" != "enabled" ]]; then + echo "Unattended mutation is paused; the missing event record is left for an operator." + exit 0 + fi + # new_patch:8.5.9 is tag 8.5.9 and recipe_rebuild:8.5.9:2 is tag 8.5.9-2. + version="${ACTION_KEY#*:}" + version="${version/:/-}" + classification="${ACTION_KEY%%:*}" + filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + event="autorelease-events/$filename" + if [[ -f "$event" ]]; then + echo "The event record for $ACTION_KEY reached main after the decision was taken." + exit 0 + fi + test "$(gh release view "$version" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)" = "false" + test "$(gh release view "$version" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = "true" + release_commit="$(gh api "repos/${{ github.repository }}/commits/$version" --jq .sha)" + [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] + # A hand-made release supplies both its assets and its own checksums, so the + # bytes are only trusted once an attestation ties them to a build of this + # repository, and the tag is only trusted once it is reachable from main. + gh release verify "$version" --repo "${{ github.repository }}" --format json \ + > autorelease-plan-download/recovered-attestation.json + git merge-base --is-ancestor "$release_commit" origin/main + assets=autorelease-plan-download/recovered-release + mkdir -p "$assets" + gh release download "$version" --repo "${{ github.repository }}" --dir "$assets" --clobber + ./scripts/validate-autorelease-archive \ + --archive "$assets/php-$version-cli-macos-aarch64.tar.gz" \ + --version "$version" + archive_digest="$(shasum -a 256 "$assets/php-$version-cli-macos-aarch64.tar.gz" | awk '{print $1}')" + grep -Fx "$archive_digest php-$version-cli-macos-aarch64.tar.gz" "$assets/SHA256SUMS" + checksums_digest="$(shasum -a 256 "$assets/SHA256SUMS" | awk '{print $1}')" + # The record states exactly what this run verified: the public release bytes. + # A fresh install is not reverifiable here, so it is never claimed as evidence. + jq -n \ + --arg actionKey "$ACTION_KEY" \ + --arg classification "$classification" \ + --arg commit "$release_commit" \ + --arg runId "${{ github.run_id }}" \ + --arg evidenceManifestDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ + '{schemaVersion:1,actionKey:$actionKey,classification:$classification,state:"release_requested",history:[],phpBinCommit:$commit,evidenceManifestDigest:$evidenceManifestDigest,recoveredByRunId:$runId}' \ + > autorelease-plan-download/recovered-event.json + jq -n \ + --arg version "$version" \ + --arg commit "$release_commit" \ + --arg archive "sha256:$archive_digest" \ + --arg checksums "sha256:$checksums_digest" \ + --arg attestation "sha256:$(shasum -a 256 autorelease-plan-download/recovered-attestation.json | awk '{print $1}')" \ + '[{kind:"published_immutable_release",version:$version,phpBinCommit:$commit,attestationDigest:$attestation,assetDigests:{("php-"+$version+"-cli-macos-aarch64.tar.gz"):$archive,"SHA256SUMS":$checksums}}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target released \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + jq -n --arg version "$version" \ + '[{kind:"public_release_bytes_reverified",version:$version,modes:["public_download"]}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json \ + --target public_install_verified \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + jq -n --arg runId "${{ github.run_id }}" '[{kind:"record_recovered_by_watcher",runId:$runId}]' \ + > autorelease-plan-download/recovery-evidence.json + ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target complete \ + --evidence autorelease-plan-download/recovery-evidence.json \ + --output autorelease-plan-download/recovered-event.next + mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json + base="$(git rev-parse HEAD)" + # The protected-controls exemption trusts this branch prefix from this + # workflow; a recovered record is filed exactly like an EOL completion. + branch="autorelease/eol-complete-${{ github.run_id }}" + git checkout -B "$branch" + cp autorelease-plan-download/recovered-event.json "$event" + git add "$event" + git -c user.name=autorelease-watcher -c user.email=autorelease@invalid \ + commit -m "chore: complete $ACTION_KEY" + head="$(git rev-parse HEAD)" + digest="sha256:$(shasum -a 256 "$event" | awk '{print $1}')" + gh auth setup-git + git push origin HEAD + url="$(gh pr create --base main --head "$branch" --title "chore: complete $ACTION_KEY" \ + --body "Recovered durable event record for an immutable published release.")" + number="${url##*/}" + ./scripts/dispatch-pr-checks \ + --pr "$number" \ + --check "Script checks" \ + --output autorelease-plan-download/recovery-checks.json + ./scripts/assert-admission-checks --require-protected-controls --checks autorelease-plan-download/recovery-checks.json + test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" + git fetch origin main + test "$(git rev-parse origin/main)" = "$base" + test "$(git rev-list --parents -n 1 "$head")" = "$head $base" + test "$(git diff --name-only "$base" "$head")" = "$event" + test "sha256:$(git show "$head:$event" | shasum -a 256 | awk '{print $1}')" = "$digest" + gh pr merge "$number" --squash --delete-branch + echo "merged=true" >> "$GITHUB_OUTPUT" + jq --arg version "$version" \ + '.severity="info" | .summary="The event record for the published PHP \($version) release was recovered by the watcher." | .finalResult="passed"' \ + "$event" > autorelease-plan-download/recovery-notification.json + ./scripts/notify-autorelease \ + --event autorelease-plan-download/recovery-notification.json \ + --state autorelease-plan-download/recovery-notification-state.json \ + --output autorelease-plan-download/recovery-notification-next.json \ + --backend github --repo "${{ github.repository }}" --owner "${{ vars.AUTORELEASE_OWNER }}" - name: Prepare deterministic no-change evidence id: evidence - if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' + if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' && steps.recover.outputs.merged != 'true' run: | git checkout -B "autorelease/evidence-${{ github.run_id }}" origin/main mkdir -p autorelease-state @@ -214,13 +330,30 @@ jobs: - name: Dispatch implementation or no-edit release env: EVIDENCE_ALREADY_RECORDED: ${{ steps.evidence.outputs.already_recorded }} + RECORD_ACTION_KEY: ${{ needs.investigate.outputs.record_action_key }} + RECOVERY_MERGED: ${{ steps.recover.outputs.merged }} GH_TOKEN: ${{ github.token }} run: | if [[ "$(jq -r .unattendedMutation .github/autorelease-operator.json)" != "enabled" ]]; then echo "Unattended mutation is paused; retained investigation remains read-only." exit 0 fi + if [[ -n "$RECORD_ACTION_KEY" ]]; then + # A recovery attempt can leave the checkout on its own branch, and every + # path below measures its write against main. + git checkout -f main + fi action="${{ needs.investigate.outputs.action }}" + if [[ -z "$action" || "$action" == "none" ]]; then + echo "The run carries no admitted plan to dispatch." + exit 0 + fi + if [[ "$RECOVERY_MERGED" == "true" && ( "$action" == "no_change" || "$action" == "branch_eol" ) ]]; then + # Both writes assert an untouched base, and the recovered record has just + # moved it; the next run replays this one against the new main. + echo "A recovered event record landed on main this run, so the $action write waits for the next run." + exit 0 + fi if [[ "$action" == "no_change" && "$EVIDENCE_ALREADY_RECORDED" == "true" ]]; then echo "The exact deterministic evidence state is already recorded." exit 0 @@ -290,99 +423,6 @@ jobs: test "sha256:$(git show "$head:autorelease-state/last-evidence.json" | shasum -a 256 | awk '{print $1}')" = "$record_digest" gh pr merge "$number" --squash --delete-branch exit 0 - elif [[ "$action" == "record_completed_event" ]]; then - action_key="${{ needs.investigate.outputs.action_key }}" - # new_patch:8.5.9 is tag 8.5.9 and recipe_rebuild:8.5.9:2 is tag 8.5.9-2. - version="${action_key#*:}" - version="${version/:/-}" - classification="${action_key%%:*}" - filename="$(printf '%s' "$action_key" | tr ':/' '--').json" - event="autorelease-events/$filename" - test ! -f "$event" - test "$(gh release view "$version" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)" = "false" - test "$(gh release view "$version" --repo "${{ github.repository }}" --json isImmutable --jq .isImmutable)" = "true" - assets=autorelease-plan-download/recovered-release - mkdir -p "$assets" - gh release download "$version" --repo "${{ github.repository }}" --dir "$assets" --clobber - ./scripts/validate-autorelease-archive \ - --archive "$assets/php-$version-cli-macos-aarch64.tar.gz" \ - --version "$version" - archive_digest="$(shasum -a 256 "$assets/php-$version-cli-macos-aarch64.tar.gz" | awk '{print $1}')" - grep -Fx "$archive_digest php-$version-cli-macos-aarch64.tar.gz" "$assets/SHA256SUMS" - checksums_digest="$(shasum -a 256 "$assets/SHA256SUMS" | awk '{print $1}')" - release_commit="$(gh api "repos/${{ github.repository }}/commits/$version" --jq .sha)" - [[ "$release_commit" =~ ^[0-9a-f]{40}$ ]] - # The record states exactly what this run verified: the public release bytes. - # A fresh install is not reverifiable here, so it is never claimed as evidence. - jq -n \ - --arg actionKey "$action_key" \ - --arg classification "$classification" \ - --arg commit "$release_commit" \ - --arg runId "${{ github.run_id }}" \ - --arg evidenceManifestDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ - '{schemaVersion:1,actionKey:$actionKey,classification:$classification,state:"release_requested",history:[],phpBinCommit:$commit,evidenceManifestDigest:$evidenceManifestDigest,recoveredByRunId:$runId}' \ - > autorelease-plan-download/recovered-event.json - jq -n \ - --arg version "$version" \ - --arg archive "sha256:$archive_digest" \ - --arg checksums "sha256:$checksums_digest" \ - '[{kind:"published_immutable_release",version:$version,assetDigests:{("php-"+$version+"-cli-macos-aarch64.tar.gz"):$archive,"SHA256SUMS":$checksums}}]' \ - > autorelease-plan-download/recovery-evidence.json - ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target released \ - --evidence autorelease-plan-download/recovery-evidence.json \ - --output autorelease-plan-download/recovered-event.next - mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json - jq -n --arg version "$version" \ - '[{kind:"public_release_bytes_reverified",version:$version,modes:["public_download"]}]' \ - > autorelease-plan-download/recovery-evidence.json - ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json \ - --target public_install_verified \ - --evidence autorelease-plan-download/recovery-evidence.json \ - --output autorelease-plan-download/recovered-event.next - mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json - jq -n --arg runId "${{ github.run_id }}" '[{kind:"record_recovered_by_watcher",runId:$runId}]' \ - > autorelease-plan-download/recovery-evidence.json - ./scripts/autorelease-event --event autorelease-plan-download/recovered-event.json --target complete \ - --evidence autorelease-plan-download/recovery-evidence.json \ - --output autorelease-plan-download/recovered-event.next - mv autorelease-plan-download/recovered-event.next autorelease-plan-download/recovered-event.json - base="$(git rev-parse HEAD)" - # The protected-controls exemption trusts this branch prefix from this - # workflow; a recovered record is filed exactly like an EOL completion. - branch="autorelease/eol-complete-${{ github.run_id }}" - git checkout -B "$branch" - cp autorelease-plan-download/recovered-event.json "$event" - git add "$event" - git -c user.name=autorelease-watcher -c user.email=autorelease@invalid \ - commit -m "chore: complete $action_key" - head="$(git rev-parse HEAD)" - digest="sha256:$(shasum -a 256 "$event" | awk '{print $1}')" - gh auth setup-git - git push origin HEAD - url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \ - --body "Recovered durable event record for an immutable published release.")" - number="${url##*/}" - ./scripts/dispatch-pr-checks \ - --pr "$number" \ - --check "Script checks" \ - --output autorelease-plan-download/recovery-checks.json - ./scripts/assert-admission-checks --require-protected-controls --checks autorelease-plan-download/recovery-checks.json - test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" - git fetch origin main - test "$(git rev-parse origin/main)" = "$base" - test "$(git rev-list --parents -n 1 "$head")" = "$head $base" - test "$(git diff --name-only "$base" "$head")" = "$event" - test "sha256:$(git show "$head:$event" | shasum -a 256 | awk '{print $1}')" = "$digest" - gh pr merge "$number" --squash --delete-branch - jq --arg version "$version" \ - '.severity="info" | .summary="The event record for the published PHP \($version) release was recovered by the watcher." | .finalResult="passed"' \ - "$event" > autorelease-plan-download/recovery-notification.json - ./scripts/notify-autorelease \ - --event autorelease-plan-download/recovery-notification.json \ - --state autorelease-plan-download/recovery-notification-state.json \ - --output autorelease-plan-download/recovery-notification-next.json \ - --backend github --repo "${{ github.repository }}" --owner "${{ vars.AUTORELEASE_OWNER }}" - exit 0 elif [[ "${{ needs.investigate.outputs.edits_required }}" == "true" ]]; then gh workflow run autorelease-implement.yml \ --repo "${{ github.repository }}" \ @@ -391,6 +431,12 @@ jobs: -f exact_base_sha="${{ needs.investigate.outputs.base_sha }}" \ -f phase=implementation elif [[ "$action" == "new_patch" || "$action" == "new_branch" || "$action" == "reconcile_partial" ]]; then + if [[ "${{ needs.investigate.outputs.action_key }}" == "$RECORD_ACTION_KEY" ]]; then + # The completed-action ledger the plan was admitted against is exactly the + # one missing this record, so the release it selects is already public. + echo "The selected action is already published and its record is being recovered." + exit 0 + fi if [[ "$action" == "new_branch" ]]; then filename="$(printf '%s' "${{ needs.investigate.outputs.action_key }}" | tr ':/' '--').json" if ! gh api "repos/bigpixelrocket/mise-php/contents/readiness/$filename?ref=main" >/dev/null 2>&1; then @@ -474,6 +520,13 @@ jobs: else echo "Action $action requires repository readiness before release." >&2 fi + - name: Report an unrecovered event record + # Raised last so the failure reaches the owner without having blocked the + # reconciliation, lifecycle, and selection paths of the same run. + if: steps.recover.outcome == 'failure' + run: | + echo "Recovering the event record for ${{ needs.investigate.outputs.record_action_key }} failed." >&2 + exit 1 notify-failure: name: Notify actionable watcher failure From 8b0c8690e0b77bd107e504e731a8ac26f50e5d8b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 15:38:57 +0300 Subject: [PATCH 14/48] fix: keep record recovery out of the shared checkout and branch name Recovery used to switch this checkout onto its own branch, which forced the dispatch step to switch it back and so discarded the unstaged evidence file the no-change step had just written: the commit died with nothing to commit, the evidence never advanced, and the resulting failure hid the real recovery error. The record is now committed from a separate worktree, the compensating checkout is gone, and the no-change branch is cut from a freshly fetched origin/main so it commits whether recovery ran, succeeded or failed. The EOL completion files on the same branch name in the same run, so a repair that fails after its push now closes the pull request it opened and deletes the remote branch before exiting, leaving that namespace clean. The exit trap returns the original status so cleanup cannot mask the failure. The re-raise step gains always() because a plain condition carries an implicit success(), which let a later failing step swallow the recovery diagnostic. --- .github/workflows/autorelease-watch.yml | 65 +++++++++++++++++-------- tests/test_autorelease.py | 12 ++++- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 2d4c87c..3e50ae9 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -263,15 +263,37 @@ jobs: # The protected-controls exemption trusts this branch prefix from this # workflow; a recovered record is filed exactly like an EOL completion. branch="autorelease/eol-complete-${{ github.run_id }}" - git checkout -B "$branch" - cp autorelease-plan-download/recovered-event.json "$event" - git add "$event" - git -c user.name=autorelease-watcher -c user.email=autorelease@invalid \ + # The record is committed from a separate worktree so this checkout keeps its + # branch and its working tree, which the later steps of this job still write. + worktree="$RUNNER_TEMP/record-recovery" + pushed=false + number="" + # The EOL dispatch path files on this same branch name in the same run, so a + # half-finished recovery must hand back a clean namespace or that push is + # rejected as a non-fast-forward. Cleanup never masks the original exit code. + cleanup() { + local status=$? + if [[ -n "$number" ]]; then + gh pr close "$number" --delete-branch || true + fi + if [[ "$pushed" == "true" ]]; then + git push origin --delete "$branch" || true + fi + git worktree remove --force "$worktree" || true + git branch -D "$branch" || true + exit "$status" + } + trap cleanup EXIT + git worktree add -B "$branch" "$worktree" HEAD + cp autorelease-plan-download/recovered-event.json "$worktree/$event" + git -C "$worktree" add "$event" + git -C "$worktree" -c user.name=autorelease-watcher -c user.email=autorelease@invalid \ commit -m "chore: complete $ACTION_KEY" - head="$(git rev-parse HEAD)" - digest="sha256:$(shasum -a 256 "$event" | awk '{print $1}')" + head="$(git -C "$worktree" rev-parse HEAD)" + digest="sha256:$(shasum -a 256 "$worktree/$event" | awk '{print $1}')" gh auth setup-git - git push origin HEAD + git -C "$worktree" push origin HEAD + pushed=true url="$(gh pr create --base main --head "$branch" --title "chore: complete $ACTION_KEY" \ --body "Recovered durable event record for an immutable published release.")" number="${url##*/}" @@ -287,10 +309,13 @@ jobs: test "$(git diff --name-only "$base" "$head")" = "$event" test "sha256:$(git show "$head:$event" | shasum -a 256 | awk '{print $1}')" = "$digest" gh pr merge "$number" --squash --delete-branch + # The merge already retired both, so cleanup has nothing left to withdraw. + number="" + pushed=false echo "merged=true" >> "$GITHUB_OUTPUT" jq --arg version "$version" \ '.severity="info" | .summary="The event record for the published PHP \($version) release was recovered by the watcher." | .finalResult="passed"' \ - "$event" > autorelease-plan-download/recovery-notification.json + "$worktree/$event" > autorelease-plan-download/recovery-notification.json ./scripts/notify-autorelease \ --event autorelease-plan-download/recovery-notification.json \ --state autorelease-plan-download/recovery-notification-state.json \ @@ -298,8 +323,14 @@ jobs: --backend github --repo "${{ github.repository }}" --owner "${{ vars.AUTORELEASE_OWNER }}" - name: Prepare deterministic no-change evidence id: evidence - if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' && steps.recover.outputs.merged != 'true' + if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' + env: + GH_TOKEN: ${{ github.token }} run: | + # A recovered record may have just moved main, and the evidence commit asserts + # an untouched base, so the branch is always cut from the current origin/main. + gh auth setup-git + git fetch origin main git checkout -B "autorelease/evidence-${{ github.run_id }}" origin/main mkdir -p autorelease-state jq -n \ @@ -338,19 +369,14 @@ jobs: echo "Unattended mutation is paused; retained investigation remains read-only." exit 0 fi - if [[ -n "$RECORD_ACTION_KEY" ]]; then - # A recovery attempt can leave the checkout on its own branch, and every - # path below measures its write against main. - git checkout -f main - fi action="${{ needs.investigate.outputs.action }}" if [[ -z "$action" || "$action" == "none" ]]; then echo "The run carries no admitted plan to dispatch." exit 0 fi - if [[ "$RECOVERY_MERGED" == "true" && ( "$action" == "no_change" || "$action" == "branch_eol" ) ]]; then - # Both writes assert an untouched base, and the recovered record has just - # moved it; the next run replays this one against the new main. + if [[ "$RECOVERY_MERGED" == "true" && "$action" == "branch_eol" ]]; then + # The completion is written into this checkout and asserts an untouched + # base, which the recovered record has just moved; the next run replays it. echo "A recovered event record landed on main this run, so the $action write waits for the next run." exit 0 fi @@ -522,8 +548,9 @@ jobs: fi - name: Report an unrecovered event record # Raised last so the failure reaches the owner without having blocked the - # reconciliation, lifecycle, and selection paths of the same run. - if: steps.recover.outcome == 'failure' + # reconciliation, lifecycle, and selection paths of the same run, and with + # always() so a later failing step cannot hide which repair went wrong. + if: always() && steps.recover.outcome == 'failure' run: | echo "Recovering the event record for ${{ needs.investigate.outputs.record_action_key }} failed." >&2 exit 1 diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index e7761f5..17894ab 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -572,13 +572,21 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): # Assets and checksums of a hand-made release prove each other and nothing else. self.assertIn('gh release verify "$version" --repo "${{ github.repository }}" --format json', recovery) self.assertIn('git merge-base --is-ancestor "$release_commit" origin/main', recovery) - # A failing repair yields to the other paths and is only raised after them. + # A failing repair yields to the other paths, is raised only after them, and + # says so even when one of those paths failed too. self.assertIn("continue-on-error: true", recovery) self.assertLess(start, watcher.index("- name: Dispatch implementation or no-edit release")) self.assertLess( watcher.index("- name: Dispatch implementation or no-edit release"), - watcher.index("if: steps.recover.outcome == 'failure'"), + watcher.index("if: always() && steps.recover.outcome == 'failure'"), ) + # Later steps keep writing this checkout, and the EOL path files on this very + # branch name in the same run, so recovery owns neither past its own step. + self.assertIn('git worktree add -B "$branch" "$worktree" HEAD', recovery) + self.assertNotIn("git checkout", recovery) + self.assertIn('git push origin --delete "$branch"', recovery) + self.assertIn('git worktree remove --force "$worktree"', recovery) + self.assertIn('exit "$status"', recovery) # A published release downgrades the publish alarm from critical to warning. self.assertIn("release-transaction-state-${{ github.run_id }}", release) self.assertIn("jq -r .released release-state/transaction-state.json", release) From 243329f307c741db2c3821f734446b1e625148f1 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 15:50:23 +0300 Subject: [PATCH 15/48] fix: keep the recovery merge from deleting a branch in use Without --repo, gh also deletes the local branch after a squash merge, and git refuses while the recovery worktree still holds it. gh wraps that error and exits non-zero, so the step aborted after the merge had already landed: the merged output was never written, the success notification never sent, a working run raised a critical failure, and a same-run EOL completion proceeded against a base that had moved. The close call in the cleanup trap names the repository for the same reason. --- .github/workflows/autorelease-watch.yml | 6 ++++-- tests/test_autorelease.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 3e50ae9..8152e53 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -274,7 +274,7 @@ jobs: cleanup() { local status=$? if [[ -n "$number" ]]; then - gh pr close "$number" --delete-branch || true + gh pr close "$number" --repo "${{ github.repository }}" --delete-branch || true fi if [[ "$pushed" == "true" ]]; then git push origin --delete "$branch" || true @@ -308,7 +308,9 @@ jobs: test "$(git rev-list --parents -n 1 "$head")" = "$head $base" test "$(git diff --name-only "$base" "$head")" = "$event" test "sha256:$(git show "$head:$event" | shasum -a 256 | awk '{print $1}')" = "$digest" - gh pr merge "$number" --squash --delete-branch + # --repo keeps the branch deletion remote-only. Without it gh also deletes the + # local branch, which git refuses while the recovery worktree still holds it. + gh pr merge "$number" --repo "${{ github.repository }}" --squash --delete-branch # The merge already retired both, so cleanup has nothing left to withdraw. number="" pushed=false diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 17894ab..5e0f35d 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -1,6 +1,7 @@ import io import json import pathlib +import re import runpy import subprocess import tarfile @@ -587,6 +588,10 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): self.assertIn('git push origin --delete "$branch"', recovery) self.assertIn('git worktree remove --force "$worktree"', recovery) self.assertIn('exit "$status"', recovery) + # Every gh call here names the repository: without it gh also deletes the local + # branch, which git refuses while the recovery worktree still holds it. + for call in re.findall(r"^\s*gh pr (?:merge|close) .*$", recovery, re.MULTILINE): + self.assertIn('--repo "${{ github.repository }}"', call) # A published release downgrades the publish alarm from critical to warning. self.assertIn("release-transaction-state-${{ github.run_id }}", release) self.assertIn("jq -r .released release-state/transaction-state.json", release) From 78cbe67ee69a008b5e36f6cb54dc331a3c2b3b7c Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 16:37:07 +0300 Subject: [PATCH 16/48] refactor: route watch actions through deterministic control table The watcher dispatch was a 168-line if/elif that exited 0 on any action combination it did not recognise. route_watch_action() in control.py now enumerates every legal shape, including the ones that legitimately do nothing, and raises otherwise; recovery keeps its own recoveryRoute field because it is an overlay on the admitted plan rather than a competing branch. The operator pause gate was inlined in three shapes across four workflows while control.mutation_allowed() was unreachable; it is now the operator-gate subcommand, with --require-enabled for the sites that must fail the job. The action-key-to-filename mapping had eight copies; it is now action-filename. Also right-sizes the coordinate job timeout from 5 to 40 minutes, since a run can complete two full PR cycles and dispatch-pr-checks waits 900s in each, and tightens the recovery re-raise condition from always() to !cancelled(). --- .github/workflows/autorelease-implement.yml | 6 +- .github/workflows/autorelease-publish.yml | 14 +- .github/workflows/autorelease-watch.yml | 156 +++++++++++--------- autorelease/control.py | 130 +++++++++++++++- autorelease/verify.py | 5 +- tests/test_autorelease.py | 114 +++++++++++++- 6 files changed, 339 insertions(+), 86 deletions(-) diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index 4ccb62b..be4bf53 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -68,7 +68,7 @@ jobs: RUN_ID: ${{ needs.preflight.outputs.run_id }} run: gh run download "$RUN_ID" --name "autorelease-investigation-$RUN_ID" --dir autorelease-run - name: Enforce operator pause - run: test "$(jq -r .unattendedMutation .github/autorelease-operator.json)" = "enabled" + run: ./autorelease/control.py operator-gate --operator-file .github/autorelease-operator.json --require-enabled - name: Verify exact admitted base env: BASE_SHA: ${{ needs.preflight.outputs.base_sha }} @@ -377,7 +377,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | action_key="$(jq -r .actionKey autorelease-run/implementation-plan.json)" - branch="autorelease/$(printf '%s' "$action_key" | tr ':/' '--')" + branch="autorelease/$(./autorelease/control.py action-filename "$action_key" --suffix '')" gh auth setup-git git push origin "HEAD:refs/heads/$branch" existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" @@ -439,7 +439,7 @@ jobs: git checkout -B "autorelease/readiness-${{ github.run_id }}" origin/main base="$(git rev-parse HEAD)" action_key="$(jq -r .actionKey autorelease-run/implementation-plan.json)" - filename="$(printf '%s' "$action_key" | tr ':/' '--').json" + filename="$(./autorelease/control.py action-filename "$action_key")" mkdir -p autorelease-events jq -n \ --arg actionKey "$action_key" \ diff --git a/.github/workflows/autorelease-publish.yml b/.github/workflows/autorelease-publish.yml index f6dbbe2..2cbc8a4 100644 --- a/.github/workflows/autorelease-publish.yml +++ b/.github/workflows/autorelease-publish.yml @@ -104,11 +104,11 @@ jobs: test "$(jq -r .releaseIntent.version admitted-run/autorelease-plan.json)" = "$VERSION" test "$(jq -r .preconditions.phpBinHead admitted-run/autorelease-plan.json)" = "$EXACT_COMMIT" test "$(jq -r .preconditions.supportPolicyDigest admitted-run/autorelease-plan.json)" = "$(./autorelease/control.py digest support-policy.json)" - test "$(jq -r .unattendedMutation .github/autorelease-operator.json)" = "enabled" + ./autorelease/control.py operator-gate --operator-file .github/autorelease-operator.json --require-enabled mkdir -p release-run gh api "repos/${{ github.repository }}/contents/.github/autorelease-operator.json?ref=main" \ --jq .content | base64 --decode > release-run/current-operator.json - test "$(jq -r .unattendedMutation release-run/current-operator.json)" = "enabled" + ./autorelease/control.py operator-gate --operator-file release-run/current-operator.json --require-enabled mise_commit="$(jq -r .sha admitted-run/evidence/raw/mise_php_state.body)" [[ "$mise_commit" =~ ^[0-9a-f]{40}$ ]] git -C mise-php fetch origin "$mise_commit" @@ -129,7 +129,7 @@ jobs: run: | action="$(jq -r .action admitted-run/autorelease-plan.json)" if [[ "$action" == "new_branch" ]]; then - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" event="autorelease-events/$filename" test -f "$event" test "$(jq -r .state "$event")" = "php_bin_ready" @@ -182,7 +182,7 @@ jobs: run: | mkdir -p release-run printf '{"schemaVersion":1,"state":"requested","history":[]}\n' > release-run/transaction.json - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" if [[ -f "autorelease-events/$filename" ]]; then cp "autorelease-events/$filename" release-run/event.json if [[ "$(jq -r .state release-run/event.json)" == "php_bin_ready" \ @@ -215,7 +215,7 @@ jobs: run: | gh api "repos/${{ github.repository }}/contents/.github/autorelease-operator.json?ref=main" \ --jq .content | base64 --decode > release-run/current-operator.json - test "$(jq -r .unattendedMutation release-run/current-operator.json)" = "enabled" + ./autorelease/control.py operator-gate --operator-file release-run/current-operator.json --require-enabled for target in built draft_created draft_verified; do ./scripts/publish-release \ --transaction release-run/transaction.json \ @@ -265,7 +265,7 @@ jobs: run: | gh api "repos/${{ github.repository }}/contents/.github/autorelease-operator.json?ref=main" \ --jq .content | base64 --decode > release-run/current-operator.json - test "$(jq -r .unattendedMutation release-run/current-operator.json)" = "enabled" + ./autorelease/control.py operator-gate --operator-file release-run/current-operator.json --require-enabled for target in published public_verified complete; do ./scripts/publish-release \ --transaction release-run/transaction.json \ @@ -352,7 +352,7 @@ jobs: git fetch origin main git checkout -B "autorelease/event-${{ github.run_id }}" origin/main base="$(git rev-parse HEAD)" - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" cp release-run/event.json "autorelease-events/$filename" git add "autorelease-events/$filename" git -c user.name=autorelease -c user.email=autorelease@invalid \ diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 8152e53..cdfccef 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -149,7 +149,9 @@ jobs: needs: investigate if: needs.investigate.outputs.action_key != '' || needs.investigate.outputs.record_action_key != '' runs-on: ubuntu-latest - timeout-minutes: 5 + # A single run can now complete two full PR cycles (a record recovery and a dispatch), + # and dispatch-pr-checks alone waits up to 900s for the required checks on each. + timeout-minutes: 40 permissions: actions: write artifact-metadata: write @@ -177,12 +179,13 @@ jobs: - name: Read unattended mutation state id: operator run: | - test "$(jq -r .unattendedMutation .github/autorelease-operator.json)" = "enabled" \ - && echo "enabled=true" >> "$GITHUB_OUTPUT" \ - || echo "enabled=false" >> "$GITHUB_OUTPUT" + if [[ "$(./autorelease/control.py operator-gate --operator-file .github/autorelease-operator.json)" == "enabled" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi - name: Recover the event record of a published release id: recover - if: needs.investigate.outputs.record_action_key != '' # A blocked or failing repair must never suppress the other watcher paths, so # the step is allowed to fail here and is re-raised as a job failure after them. continue-on-error: true @@ -190,7 +193,21 @@ jobs: ACTION_KEY: ${{ needs.investigate.outputs.record_action_key }} GH_TOKEN: ${{ github.token }} run: | - if [[ "$(jq -r .unattendedMutation .github/autorelease-operator.json)" != "enabled" ]]; then + # The same table that routes the dispatch decides whether a repair is due; this + # step owns the recovery overlay, which runs beside any admitted plan route. + route="$(./autorelease/control.py route-watch-action --record-action-key "$ACTION_KEY" | jq -r .recoveryRoute)" + case "$route" in + none) + echo "No published release is missing its event record." + exit 0 + ;; + recover_record) ;; + *) + echo "unrouted recovery route: $route" >&2 + exit 1 + ;; + esac + if [[ "$(./autorelease/control.py operator-gate --operator-file .github/autorelease-operator.json)" != "enabled" ]]; then echo "Unattended mutation is paused; the missing event record is left for an operator." exit 0 fi @@ -198,7 +215,7 @@ jobs: version="${ACTION_KEY#*:}" version="${version/:/-}" classification="${ACTION_KEY%%:*}" - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" event="autorelease-events/$filename" if [[ -f "$event" ]]; then echo "The event record for $ACTION_KEY reached main after the decision was taken." @@ -362,49 +379,34 @@ jobs: predicate-path: autorelease-plan-download/evidence-attestation-predicate.json - name: Dispatch implementation or no-edit release env: + ACTION: ${{ needs.investigate.outputs.action }} + ACTION_KEY: ${{ needs.investigate.outputs.action_key }} + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} + EDITS_REQUIRED: ${{ needs.investigate.outputs.edits_required }} EVIDENCE_ALREADY_RECORDED: ${{ steps.evidence.outputs.already_recorded }} RECORD_ACTION_KEY: ${{ needs.investigate.outputs.record_action_key }} RECOVERY_MERGED: ${{ steps.recover.outputs.merged }} GH_TOKEN: ${{ github.token }} run: | - if [[ "$(jq -r .unattendedMutation .github/autorelease-operator.json)" != "enabled" ]]; then + if [[ "$(./autorelease/control.py operator-gate --operator-file .github/autorelease-operator.json)" != "enabled" ]]; then echo "Unattended mutation is paused; retained investigation remains read-only." exit 0 fi - action="${{ needs.investigate.outputs.action }}" - if [[ -z "$action" || "$action" == "none" ]]; then - echo "The run carries no admitted plan to dispatch." - exit 0 - fi - if [[ "$RECOVERY_MERGED" == "true" && "$action" == "branch_eol" ]]; then - # The completion is written into this checkout and asserts an untouched - # base, which the recovered record has just moved; the next run replays it. - echo "A recovered event record landed on main this run, so the $action write waits for the next run." - exit 0 - fi - if [[ "$action" == "no_change" && "$EVIDENCE_ALREADY_RECORDED" == "true" ]]; then - echo "The exact deterministic evidence state is already recorded." - exit 0 - fi - if [[ "$action" == "blocked" || "$action" == "needs_human" ]]; then + # One deterministic table maps the admitted decision to exactly one route. An + # unrouted combination raises there, so it fails this step instead of exiting 0. + decision="$(./autorelease/control.py route-watch-action \ + --action "$ACTION" \ + --action-key "$ACTION_KEY" \ + --record-action-key "$RECORD_ACTION_KEY" \ + --edits-required "$EDITS_REQUIRED" \ + --recovery-merged "$RECOVERY_MERGED" \ + --evidence-already-recorded "$EVIDENCE_ALREADY_RECORDED")" + route="$(jq -r .route <<< "$decision")" + reason="$(jq -r .reason <<< "$decision")" + # Lifecycle actions announce themselves whichever route then carries them. + if [[ "$(jq -r .notify <<< "$decision")" == "lifecycle" ]]; then jq -n \ - --arg actionKey "${{ needs.investigate.outputs.action_key }}" \ - --arg state "$action" \ - --arg summary "$(jq -r .summary autorelease-plan-download/autorelease-plan.json)" \ - --arg evidenceDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ - '{actionKey:$actionKey,state:$state,severity:"warning",humanActionRequired:($state=="needs_human"),summary:$summary,evidenceDigest:$evidenceDigest}' \ - > autorelease-plan-download/notification-event.json - ./scripts/notify-autorelease \ - --event autorelease-plan-download/notification-event.json \ - --state autorelease-plan-download/notification-state.json \ - --output autorelease-plan-download/notification-next.json \ - --backend github \ - --repo "${{ github.repository }}" \ - --owner "${{ vars.AUTORELEASE_OWNER }}" - exit 0 - elif [[ "$action" == "new_branch" || "$action" == "branch_eol" ]]; then - jq -n \ - --arg actionKey "${{ needs.investigate.outputs.action_key }}" \ + --arg actionKey "$ACTION_KEY" \ --arg summary "$(jq -r .notification.summary autorelease-plan-download/autorelease-plan.json)" \ --arg evidenceDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ '{actionKey:$actionKey,state:"detected",severity:"info",humanActionRequired:false,summary:$summary,evidenceDigest:$evidenceDigest}' \ @@ -417,8 +419,27 @@ jobs: --repo "${{ github.repository }}" \ --owner "${{ vars.AUTORELEASE_OWNER }}" fi - - if [[ "$action" == "no_change" ]]; then + case "$route" in + none) + echo "No dispatch is routed for '$ACTION': $reason." + ;; + notify_blocked) + jq -n \ + --arg actionKey "$ACTION_KEY" \ + --arg state "$ACTION" \ + --arg summary "$(jq -r .summary autorelease-plan-download/autorelease-plan.json)" \ + --arg evidenceDigest "$(jq -r .manifestDigest autorelease-plan-download/evidence/evidence-manifest.json)" \ + '{actionKey:$actionKey,state:$state,severity:"warning",humanActionRequired:($state=="needs_human"),summary:$summary,evidenceDigest:$evidenceDigest}' \ + > autorelease-plan-download/notification-event.json + ./scripts/notify-autorelease \ + --event autorelease-plan-download/notification-event.json \ + --state autorelease-plan-download/notification-state.json \ + --output autorelease-plan-download/notification-next.json \ + --backend github \ + --repo "${{ github.repository }}" \ + --owner "${{ vars.AUTORELEASE_OWNER }}" + ;; + no_change_evidence) branch="autorelease/evidence-${{ github.run_id }}" base="$(git rev-parse HEAD)" git add autorelease-state/last-evidence.json @@ -450,23 +471,18 @@ jobs: test "$(git diff --name-only "$base" "$head")" = "autorelease-state/last-evidence.json" test "sha256:$(git show "$head:autorelease-state/last-evidence.json" | shasum -a 256 | awk '{print $1}')" = "$record_digest" gh pr merge "$number" --squash --delete-branch - exit 0 - elif [[ "${{ needs.investigate.outputs.edits_required }}" == "true" ]]; then + ;; + dispatch_implementation) gh workflow run autorelease-implement.yml \ --repo "${{ github.repository }}" \ --ref main \ -f investigation_run_id="${{ github.run_id }}" \ - -f exact_base_sha="${{ needs.investigate.outputs.base_sha }}" \ + -f exact_base_sha="$BASE_SHA" \ -f phase=implementation - elif [[ "$action" == "new_patch" || "$action" == "new_branch" || "$action" == "reconcile_partial" ]]; then - if [[ "${{ needs.investigate.outputs.action_key }}" == "$RECORD_ACTION_KEY" ]]; then - # The completed-action ledger the plan was admitted against is exactly the - # one missing this record, so the release it selects is already public. - echo "The selected action is already published and its record is being recovered." - exit 0 - fi - if [[ "$action" == "new_branch" ]]; then - filename="$(printf '%s' "${{ needs.investigate.outputs.action_key }}" | tr ':/' '--').json" + ;; + dispatch_publish) + if [[ "$ACTION" == "new_branch" ]]; then + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" if ! gh api "repos/bigpixelrocket/mise-php/contents/readiness/$filename?ref=main" >/dev/null 2>&1; then echo "Waiting for exact mise-php readiness for $filename." exit 0 @@ -476,13 +492,13 @@ jobs: gh workflow run autorelease-publish.yml \ --repo "${{ github.repository }}" \ --ref main \ - -f action_key="${{ needs.investigate.outputs.action_key }}" \ + -f action_key="$ACTION_KEY" \ -f investigation_run_id="${{ github.run_id }}" \ -f version="$version" \ - -f exact_commit="${{ needs.investigate.outputs.base_sha }}" - elif [[ "$action" == "branch_eol" ]]; then - action_key="${{ needs.investigate.outputs.action_key }}" - filename="$(printf '%s' "$action_key" | tr ':/' '--').json" + -f exact_commit="$BASE_SHA" + ;; + complete_branch_eol) + filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" if ! gh api "repos/bigpixelrocket/mise-php/contents/readiness/$filename?ref=main" \ --jq .content > autorelease-plan-download/mise-readiness.b64; then echo "Waiting for exact mise-php EOL readiness for $filename." @@ -492,7 +508,7 @@ jobs: > autorelease-plan-download/mise-readiness.json event="autorelease-events/$filename" test -f "$event" - test "$(jq -r .actionKey autorelease-plan-download/mise-readiness.json)" = "$action_key" + test "$(jq -r .actionKey autorelease-plan-download/mise-readiness.json)" = "$ACTION_KEY" test "$(jq -r .ready autorelease-plan-download/mise-readiness.json)" = "true" test "$(jq -r .supportPolicyDigest "$event")" = "$(jq -r .policyDigest autorelease-plan-download/mise-readiness.json)" test "$(jq -r .policyInvariantsDigest "$event")" = "$(jq -r .policyInvariantsDigest autorelease-plan-download/mise-readiness.json)" @@ -518,12 +534,12 @@ jobs: git checkout -B "$branch" git add "$event" git -c user.name=autorelease-lifecycle -c user.email=autorelease@invalid \ - commit -m "chore: complete $action_key" + commit -m "chore: complete $ACTION_KEY" head="$(git rev-parse HEAD)" digest="sha256:$(shasum -a 256 "$event" | awk '{print $1}')" gh auth setup-git git push origin HEAD - url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \ + url="$(gh pr create --base main --head "$branch" --title "chore: complete $ACTION_KEY" \ --body "Deterministic EOL completion bound to exact cross-repository readiness.")" number="${url##*/}" ./scripts/dispatch-pr-checks \ @@ -545,14 +561,18 @@ jobs: --state autorelease-plan-download/eol-notification-state.json \ --output autorelease-plan-download/eol-notification-next.json \ --backend github --repo "${{ github.repository }}" --owner "${{ vars.AUTORELEASE_OWNER }}" - else - echo "Action $action requires repository readiness before release." >&2 - fi + ;; + *) + echo "unrouted action" >&2 + exit 1 + ;; + esac - name: Report an unrecovered event record # Raised last so the failure reaches the owner without having blocked the # reconciliation, lifecycle, and selection paths of the same run, and with - # always() so a later failing step cannot hide which repair went wrong. - if: always() && steps.recover.outcome == 'failure' + # !cancelled() so a later failing step cannot hide which repair went wrong, + # while a cancelled run stops instead of reporting a repair it never finished. + if: ${{ !cancelled() && steps.recover.outcome == 'failure' }} run: | echo "Recovering the event record for ${{ needs.investigate.outputs.record_action_key }} failed." >&2 exit 1 diff --git a/autorelease/control.py b/autorelease/control.py index 0fcbd30..f8d2acb 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -964,9 +964,19 @@ def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any] return None -def event_record_filename(action_key: str) -> str: - """Return the single record filename an action key may occupy.""" - return action_key.translate(str.maketrans({":": "-", "/": "-"})) + ".json" +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. + + Every event record, readiness record, and automation branch in both repositories is + named from its action key by this one mapping, so the name is only ever derived here. + The key is model-authored and reaches shell arguments and repository paths, so its + alphabet is re-asserted at this boundary rather than trusted from the caller. + """ + require(bool(ACTION_KEY_RE.fullmatch(action_key)), f"invalid action key: {action_key}") + return action_key.translate(ACTION_FILENAME_MAP) + suffix def unrecorded_published_release( @@ -997,7 +1007,7 @@ def unrecorded_published_release( if tag is None: continue key = f"recipe_rebuild:{tag.group(1)}:{tag.group(2)}" if tag.group(2) else f"new_patch:{tag.group(1)}" - if key not in recorded and event_record_filename(key) not in occupied: + if key not in recorded and action_filename(key) not in occupied: keys.add(key) return min(keys, default=None) @@ -1066,6 +1076,69 @@ def watch_decision( } +# Only these two admitted actions announce themselves before their route runs, and only +# these three select a release for the publish transaction. +WATCH_LIFECYCLE_NOTIFICATION_ACTIONS = frozenset({"new_branch", "branch_eol"}) +WATCH_PUBLISH_ACTIONS = frozenset({"new_patch", "new_branch", "reconcile_partial"}) + + +def route_watch_action(decision: dict[str, Any]) -> dict[str, Any]: + """Return the one route a coordinated watcher decision takes, or raise. + + The watcher runs two independent routes in the same job: `route` dispatches the + admitted plan, and `recoveryRoute` repairs a published release that has no event + record. Recovery is an overlay rather than an exclusive branch, so it carries its own + field and never competes with the plan for one. + + Every legal combination is enumerated, including the ones that legitimately do + nothing — those return `route: "none"` with the reason, so an idle run stays green. + Anything else raises instead of falling through to a silent success, which is what an + unrouted combination used to do. + """ + action = str(decision.get("action") or "") + action_key = str(decision.get("actionKey") or "") + record_action_key = str(decision.get("recordActionKey") or "") + edits_required = bool(decision.get("editsRequired")) + recovery_merged = bool(decision.get("recoveryMerged")) + evidence_recorded = bool(decision.get("evidenceAlreadyRecorded")) + + def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: + return { + "schemaVersion": 1, + "route": route, + "reason": reason, + "notify": notify, + "action": action, + "actionKey": action_key, + "recordActionKey": record_action_key, + "recoveryRoute": "recover_record" if record_action_key else "none", + } + + if action in {"", "none"}: + return routed("none", "no_admitted_plan") + if recovery_merged and action == "branch_eol": + # The completion asserts an untouched base, which the recovered record just moved. + return routed("none", "eol_completion_deferred_by_recovery") + if action == "no_change" and evidence_recorded: + return routed("none", "evidence_state_already_recorded") + if action in {"blocked", "needs_human"}: + return routed("notify_blocked", "operator_attention_required") + notify = "lifecycle" if action in WATCH_LIFECYCLE_NOTIFICATION_ACTIONS else "none" + if action == "no_change": + return routed("no_change_evidence", "record_reviewed_evidence", notify) + if edits_required: + return routed("dispatch_implementation", "admitted_plan_requires_edits", notify) + if action in WATCH_PUBLISH_ACTIONS: + if record_action_key and action_key == record_action_key: + # The ledger this plan was admitted against is the one missing this record, + # so the release it selects is already public. + return routed("none", "release_published_pending_record", notify) + return routed("dispatch_publish", "publish_admitted_release", notify) + if action == "branch_eol": + return routed("complete_branch_eol", "complete_admitted_eol", notify) + raise ControlError(f"watcher action is unrouted: {action} with editsRequired={edits_required}") + + def retry_decision( event: dict[str, Any], failure_fingerprint: str, @@ -1248,6 +1321,12 @@ def validate_archive(archive: pathlib.Path, version: str) -> None: require("bin/php" in names, "archive does not contain bin/php") +def cli_flag(value: str, name: str) -> bool: + """Read a workflow-supplied boolean, where a skipped step legitimately supplies none.""" + require(value in {"", "true", "false"}, f"{name} must be true, false, or empty") + return value == "true" + + def cli_error(error: Exception) -> int: print(f"autorelease control rejected input: {error}", file=sys.stderr) return 1 @@ -1278,6 +1357,20 @@ def main(argv: list[str] | None = None) -> int: event_parser.add_argument("--evidence", required=True, type=pathlib.Path) event_parser.add_argument("--output", required=True, type=pathlib.Path) + route_parser = subparsers.add_parser("route-watch-action") + for name in ("--action", "--action-key", "--record-action-key"): + route_parser.add_argument(name, default="") + for name in ("--edits-required", "--recovery-merged", "--evidence-already-recorded"): + route_parser.add_argument(name, default="") + + operator_parser = subparsers.add_parser("operator-gate") + operator_parser.add_argument("--operator-file", required=True, type=pathlib.Path) + operator_parser.add_argument("--require-enabled", action="store_true") + + filename_parser = subparsers.add_parser("action-filename") + filename_parser.add_argument("action_key") + filename_parser.add_argument("--suffix", default=".json") + archive_parser = subparsers.add_parser("validate-archive") archive_parser.add_argument("--archive", required=True, type=pathlib.Path) archive_parser.add_argument("--version", required=True) @@ -1309,6 +1402,35 @@ def main(argv: list[str] | None = None) -> int: updated = transition_event(load_json(args.event), args.target, load_json(args.evidence)) write_json(args.output, updated) print(json.dumps(updated)) + elif args.command == "route-watch-action": + print( + json.dumps( + route_watch_action( + { + "action": args.action, + "actionKey": args.action_key, + "recordActionKey": args.record_action_key, + "editsRequired": cli_flag(args.edits_required, "--edits-required"), + "recoveryMerged": cli_flag(args.recovery_merged, "--recovery-merged"), + "evidenceAlreadyRecorded": cli_flag( + args.evidence_already_recorded, "--evidence-already-recorded" + ), + } + ) + ) + ) + elif args.command == "operator-gate": + state = load_json(args.operator_file) + require(isinstance(state, dict), "operator control is not an object") + require( + state.get("unattendedMutation") in {"enabled", "paused"}, + "operator control carries an unknown unattended mutation state", + ) + allowed = mutation_allowed(state) + require(allowed or not args.require_enabled, "unattended mutation is paused") + print("enabled" if allowed else "paused") + elif args.command == "action-filename": + print(action_filename(args.action_key, args.suffix)) elif args.command == "validate-archive": validate_archive(args.archive, args.version) print(json.dumps({"valid": True})) diff --git a/autorelease/verify.py b/autorelease/verify.py index f88de3e..3853130 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -752,7 +752,7 @@ def a18(self, directory: pathlib.Path) -> list[str]: dispatch_steps = [step for _, _, step in watch_steps if "gh workflow run" in (step.get("run") or "")] assert_true(dispatch_steps, "watcher no longer dispatches downstream mutation") assert_true( - all("unattendedMutation" in step["run"] for step in dispatch_steps), + all("operator-gate" in step["run"] for step in dispatch_steps), "watcher pause does not stop downstream mutation", ) release_steps = workflow_steps(load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml")) @@ -765,8 +765,7 @@ def a18(self, directory: pathlib.Path) -> list[str]: assert_true( all( job_name == "release" - and "current-operator.json" in step["run"] - and "unattendedMutation" in step["run"] + and "operator-gate --operator-file release-run/current-operator.json" in step["run"] for job_name, step in effect_steps ), "release effects are not gated by the live operator state", diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 5e0f35d..9776150 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -1,3 +1,4 @@ +import contextlib import io import json import pathlib @@ -13,9 +14,12 @@ ACTION_KEY_RE, COMPLETION_EVIDENCE_REF_RE, ControlError, + action_filename, canonical_json, load_plan_evidence, + main as control_main, mutation_allowed, + route_watch_action, notification_decision, retained_notification_issue, release_transition, @@ -37,6 +41,14 @@ ) +def run_control(*argv: str) -> tuple[int, str]: + """Run a control CLI subcommand exactly as a workflow would, capturing its output.""" + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + status = control_main(list(argv)) + return status, out.getvalue().strip() + + class AutoreleaseControlTests(unittest.TestCase): @staticmethod def _contract(): @@ -489,6 +501,102 @@ def test_retry_and_pause_bounds(self): self.assertFalse(mutation_allowed({"unattendedMutation": "paused"})) self.assertTrue(mutation_allowed({"unattendedMutation": "enabled"})) + def test_action_filename(self): + self.assertEqual("branch_eol-8.2-2026-12-31.json", action_filename("branch_eol:8.2:2026-12-31")) + self.assertEqual("new_patch-8.5.9", action_filename("new_patch:8.5.9", "")) + with self.assertRaises(ControlError): + action_filename("../escape") + + def test_route_watch_action_covers_every_decision(self): + def route(**decision): + return route_watch_action(decision) + + # No-op routes stay green: an idle run must not fail the watcher. + self.assertEqual("none", route()["route"]) + self.assertEqual("no_admitted_plan", route(action="none")["reason"]) + self.assertEqual( + "eol_completion_deferred_by_recovery", + route(action="branch_eol", recoveryMerged=True)["reason"], + ) + self.assertEqual( + "evidence_state_already_recorded", + route(action="no_change", evidenceAlreadyRecorded=True)["reason"], + ) + self.assertEqual( + "release_published_pending_record", + route(action="new_patch", actionKey="new_patch:8.5.9", recordActionKey="new_patch:8.5.9")["reason"], + ) + # Dispatching routes. + self.assertEqual("notify_blocked", route(action="blocked")["route"]) + self.assertEqual("notify_blocked", route(action="needs_human")["route"]) + self.assertEqual("no_change_evidence", route(action="no_change")["route"]) + self.assertEqual("dispatch_implementation", route(action="repair", editsRequired=True)["route"]) + self.assertEqual("dispatch_implementation", route(action="new_branch", editsRequired=True)["route"]) + self.assertEqual("dispatch_publish", route(action="new_patch")["route"]) + self.assertEqual("dispatch_publish", route(action="new_branch")["route"]) + self.assertEqual("dispatch_publish", route(action="reconcile_partial")["route"]) + self.assertEqual("complete_branch_eol", route(action="branch_eol")["route"]) + # Recovery is an overlay: it carries its own route beside any plan route. + self.assertEqual("none", route(action="new_patch")["recoveryRoute"]) + self.assertEqual( + "recover_record", + route(action="new_patch", recordActionKey="recipe_rebuild:8.5.9:2")["recoveryRoute"], + ) + self.assertEqual("recover_record", route(recordActionKey="new_patch:8.5.9")["recoveryRoute"]) + # Only the lifecycle actions notify, and blocked plans notify through their route. + self.assertEqual("lifecycle", route(action="new_branch")["notify"]) + self.assertEqual("lifecycle", route(action="branch_eol")["notify"]) + self.assertEqual("none", route(action="new_patch")["notify"]) + self.assertEqual("none", route(action="blocked")["notify"]) + # Unrouted combinations fail loudly instead of exiting green. + with self.assertRaises(ControlError): + route_watch_action({"action": "repair", "editsRequired": False}) + with self.assertRaises(ControlError): + route_watch_action({"action": "recipe_rebuild", "editsRequired": False}) + + def test_operator_gate_blocks_paused_state(self): + self.assertTrue(mutation_allowed({"unattendedMutation": "enabled"})) + self.assertFalse(mutation_allowed({"unattendedMutation": "paused"})) + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + enabled = root / "enabled.json" + enabled.write_text('{"schemaVersion":1,"unattendedMutation":"enabled"}\n') + paused = root / "paused.json" + paused.write_text('{"schemaVersion":1,"unattendedMutation":"paused"}\n') + unknown = root / "unknown.json" + unknown.write_text('{"schemaVersion":1}\n') + self.assertEqual((0, "enabled"), run_control("operator-gate", "--operator-file", str(enabled))) + self.assertEqual((0, "paused"), run_control("operator-gate", "--operator-file", str(paused))) + self.assertEqual( + (0, "enabled"), + run_control("operator-gate", "--operator-file", str(enabled), "--require-enabled"), + ) + # A paused control and an unreadable control both refuse the hard gate. + self.assertEqual( + 1, run_control("operator-gate", "--operator-file", str(paused), "--require-enabled")[0] + ) + self.assertEqual(1, run_control("operator-gate", "--operator-file", str(unknown))[0]) + self.assertEqual(1, run_control("operator-gate", "--operator-file", str(root / "absent.json"))[0]) + + def test_route_watch_action_cli_reports_the_route(self): + status, output = run_control( + "route-watch-action", + "--action", "new_patch", + "--action-key", "new_patch:8.5.9", + "--record-action-key", "new_patch:8.5.9", + "--edits-required", "false", + ) + self.assertEqual(0, status) + self.assertEqual( + {"route": "none", "reason": "release_published_pending_record", "recoveryRoute": "recover_record"}, + {key: json.loads(output)[key] for key in ("route", "reason", "recoveryRoute")}, + ) + self.assertEqual("new_patch-8.5.9.json", run_control("action-filename", "new_patch:8.5.9")[1]) + # An unrouted combination exits non-zero rather than dispatching nothing quietly. + self.assertEqual(1, run_control("route-watch-action", "--action", "repair")[0]) + # Only exact booleans reach the table. + self.assertEqual(1, run_control("route-watch-action", "--action", "repair", "--edits-required", "yes")[0]) + def test_invariants_and_durable_state_are_protected(self): self.assertTrue(path_is_protected(".github/codex-action-contract.json")) self.assertTrue(path_is_protected("autorelease/policy-invariants.json")) @@ -579,8 +687,12 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): self.assertLess(start, watcher.index("- name: Dispatch implementation or no-edit release")) self.assertLess( watcher.index("- name: Dispatch implementation or no-edit release"), - watcher.index("if: always() && steps.recover.outcome == 'failure'"), + watcher.index("if: ${{ !cancelled() && steps.recover.outcome == 'failure' }}"), ) + # The recovery overlay is routed by the same table as the dispatch, so an + # unrouted repair fails loudly instead of skipping the step silently. + self.assertIn("route-watch-action --record-action-key", recovery) + self.assertIn("recoveryRoute", recovery) # Later steps keep writing this checkout, and the EOL path files on this very # branch name in the same run, so recovery owns neither past its own step. self.assertIn('git worktree add -B "$branch" "$worktree" HEAD', recovery) From 9c632b894619d469fb5a7da5bb399249b08f974e Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:01:26 +0300 Subject: [PATCH 17/48] fix: route the recovery action and assert the hard operator gates route_watch_action raised on watch_decision's own output: every record_missing run carries action record_completed_event, which no row matched, so composing the two functions hit the raise. Only the workflow's use of the plan action kept that latent. The recovery action now has its own row, and the recovery key is read from either the separate argument the workflow passes or the decision's own actionKey, so both conventions answer the same. The a18 guard asserted the operator-gate subcommand appeared, not that it was armed. Without --require-enabled the subcommand prints the state and exits 0, so stripping the flag from every publish site left a18 green while a paused operator would still have released. a18 now asserts the invocation: the publish and implement sites must carry the flag, the watcher sites must not and must test the reported state, keeping the two shapes mutually exclusive. --- autorelease/control.py | 13 +++++++++++-- autorelease/verify.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_autorelease.py | 19 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/autorelease/control.py b/autorelease/control.py index f8d2acb..f883493 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -1079,6 +1079,9 @@ def watch_decision( # Only these two admitted actions announce themselves before their route runs, and only # these three select a release for the publish transaction. WATCH_LIFECYCLE_NOTIFICATION_ACTIONS = frozenset({"new_branch", "branch_eol"}) +# `watch_decision` names a missing event record as its own action. The recovery overlay +# owns that repair, so it is a route the plan never takes rather than an unrouted one. +WATCH_RECOVERY_ACTION = "record_completed_event" WATCH_PUBLISH_ACTIONS = frozenset({"new_patch", "new_branch", "reconcile_partial"}) @@ -1102,6 +1105,10 @@ def route_watch_action(decision: dict[str, Any]) -> dict[str, Any]: recovery_merged = bool(decision.get("recoveryMerged")) evidence_recorded = bool(decision.get("evidenceAlreadyRecorded")) + # The workflow passes the recovery key separately, but a caller handing this function + # a raw `watch_decision` carries it as that decision's own key, so both are accepted. + recovery_key = record_action_key or (action_key if action == WATCH_RECOVERY_ACTION else "") + def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: return { "schemaVersion": 1, @@ -1110,12 +1117,14 @@ def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: "notify": notify, "action": action, "actionKey": action_key, - "recordActionKey": record_action_key, - "recoveryRoute": "recover_record" if record_action_key else "none", + "recordActionKey": recovery_key, + "recoveryRoute": "recover_record" if recovery_key else "none", } if action in {"", "none"}: return routed("none", "no_admitted_plan") + if action == WATCH_RECOVERY_ACTION: + return routed("none", "recovery_routed_by_recovery_route") if recovery_merged and action == "branch_eol": # The completion asserts an untouched base, which the recovered record just moved. return routed("none", "eol_completion_deferred_by_recovery") diff --git a/autorelease/verify.py b/autorelease/verify.py index 3853130..7299cce 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -84,6 +84,17 @@ def workflow_steps(document: dict[str, Any]) -> list[tuple[str, int, dict[str, A ] +def operator_gate_calls(run: str) -> list[str]: + """Return every operator-gate invocation in a workflow step, one per line. + + The gate has two deliberate shapes. With `--require-enabled` the subcommand fails the + job; without it the subcommand only prints the state and exits 0, so a hard site that + loses the flag still reads like a gate while gating nothing. Callers therefore have to + inspect the invocation itself, not merely the presence of the subcommand name. + """ + return [line.strip() for line in run.splitlines() if "operator-gate" in line] + + def credential_sites(node: Any, path: str) -> list[str]: """Return every path in a parsed workflow whose keys or values name the OpenAI credential. @@ -755,6 +766,29 @@ def a18(self, directory: pathlib.Path) -> list[str]: all("operator-gate" in step["run"] for step in dispatch_steps), "watcher pause does not stop downstream mutation", ) + # The watcher gates are the soft shape on purpose: they log their own message and + # exit 0. That is only safe while they test the reported state, so assert the + # comparison and assert the absence of the flag, keeping them distinguishable from + # the hard sites rather than letting either shape satisfy one check. + soft_gate_calls = [call for _, _, step in watch_steps for call in operator_gate_calls(step.get("run") or "")] + assert_true(soft_gate_calls, "the watcher no longer reads the operator control") + assert_true( + all('"enabled"' in call and "--require-enabled" not in call for call in soft_gate_calls), + "a watcher operator gate neither tests the reported state nor fails the job", + ) + # Every gate outside the watcher must fail its job, which is the flag rather than + # the subcommand: without it the gate reports the state and the job releases anyway. + hard_gate_calls = [ + call + for name in ("autorelease-publish.yml", "autorelease-implement.yml") + for _, _, step in workflow_steps(load_workflow(PHP_ROOT / ".github/workflows" / name)) + for call in operator_gate_calls(step.get("run") or "") + ] + assert_true(hard_gate_calls, "the release and implementation workflows no longer read the operator control") + assert_true( + all("--require-enabled" in call for call in hard_gate_calls), + "an operator gate that must fail its job only reports the state", + ) release_steps = workflow_steps(load_workflow(PHP_ROOT / ".github/workflows/autorelease-publish.yml")) effect_steps = [ (job_name, step) @@ -765,7 +799,7 @@ def a18(self, directory: pathlib.Path) -> list[str]: assert_true( all( job_name == "release" - and "operator-gate --operator-file release-run/current-operator.json" in step["run"] + and "operator-gate --operator-file release-run/current-operator.json --require-enabled" in step["run"] for job_name, step in effect_steps ), "release effects are not gated by the live operator state", diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 9776150..aa189d6 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -543,6 +543,25 @@ def route(**decision): route(action="new_patch", recordActionKey="recipe_rebuild:8.5.9:2")["recoveryRoute"], ) self.assertEqual("recover_record", route(recordActionKey="new_patch:8.5.9")["recoveryRoute"]) + # Composing the two functions is the reading their names invite, so a raw + # watch_decision must route rather than raise: its own action names the repair the + # recovery overlay owns, and its own key is the key that overlay recovers. + missing_record = watch_decision( + self._releases_manifest(), + self._releases_manifest(), + [{"actionKey": "new_patch:8.5.8", "state": "complete"}], + {"healthy": True}, + releases=[ + {"tag_name": "8.5.9", "draft": False, "prerelease": False, "immutable": True}, + {"tag_name": "8.5.8", "draft": False, "prerelease": False, "immutable": True}, + ], + ) + self.assertEqual("record_completed_event", missing_record["action"]) + composed = route_watch_action(missing_record) + self.assertEqual("none", composed["route"]) + self.assertEqual("recovery_routed_by_recovery_route", composed["reason"]) + self.assertEqual("recover_record", composed["recoveryRoute"]) + self.assertEqual("new_patch:8.5.9", composed["recordActionKey"]) # Only the lifecycle actions notify, and blocked plans notify through their route. self.assertEqual("lifecycle", route(action="new_branch")["notify"]) self.assertEqual("lifecycle", route(action="branch_eol")["notify"]) From 9e82fd186f339d8b750735786fce35980bbe36e4 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:20:19 +0300 Subject: [PATCH 18/48] chore: delete unreferenced schemas autorelease-event, policy-invariants, and support-policy were never wired to a validator or a workflow. The only schemas that carry weight are the ones a workflow names via --output-schema, which is exactly how scripts/validate-structured-output-schemas discovers them, so these three could not be reached even in principle. Zero-reference proof (the only hits are each file's own "$id" line and the plan document describing this deletion): $ grep -rn "autorelease-event.schema.json" --exclude-dir=.git --exclude-dir=.build . docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md:674:... schemas/autorelease-event.schema.json:3: "$id": ".../autorelease-event.schema.json", $ grep -rn "policy-invariants.schema.json" --exclude-dir=.git --exclude-dir=.build . docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md:674:... schemas/policy-invariants.schema.json:3: "$id": ".../policy-invariants.schema.json", $ grep -rn "support-policy.schema.json" --exclude-dir=.git --exclude-dir=.build . docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md:674:... schemas/support-policy.schema.json:3: "$id": ".../support-policy.schema.json", --- schemas/autorelease-event.schema.json | 12 ------------ schemas/policy-invariants.schema.json | 24 ------------------------ schemas/support-policy.schema.json | 15 --------------- 3 files changed, 51 deletions(-) delete mode 100644 schemas/autorelease-event.schema.json delete mode 100644 schemas/policy-invariants.schema.json delete mode 100644 schemas/support-policy.schema.json diff --git a/schemas/autorelease-event.schema.json b/schemas/autorelease-event.schema.json deleted file mode 100644 index f737e04..0000000 --- a/schemas/autorelease-event.schema.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://bigpixelrocket.dev/schemas/autorelease-event.schema.json", - "type": "object", - "required": ["schemaVersion", "actionKey", "state", "history"], - "properties": { - "schemaVersion": {"const": 1}, - "actionKey": {"type": "string"}, - "state": {"enum": ["detected", "php_bin_ready", "mise_ready", "release_requested", "released", "public_install_verified", "complete", "blocked", "needs_human"]}, - "history": {"type": "array"} - } -} diff --git a/schemas/policy-invariants.schema.json b/schemas/policy-invariants.schema.json deleted file mode 100644 index affc22d..0000000 --- a/schemas/policy-invariants.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://bigpixelrocket.dev/schemas/policy-invariants.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schemaVersion", "target", "allowPrereleases", "historicalExactVersionsRemainInstallable", "immutablePublishedAssets"], - "properties": { - "schemaVersion": {"const": 1}, - "target": { - "type": "object", - "additionalProperties": false, - "required": ["os", "minimumVersion", "architecture", "sapi"], - "properties": { - "os": {"const": "macOS"}, - "minimumVersion": {"const": "26.0"}, - "architecture": {"const": "arm64"}, - "sapi": {"const": "cli"} - } - }, - "allowPrereleases": {"const": false}, - "historicalExactVersionsRemainInstallable": {"const": true}, - "immutablePublishedAssets": {"const": true} - } -} diff --git a/schemas/support-policy.schema.json b/schemas/support-policy.schema.json deleted file mode 100644 index 961ec31..0000000 --- a/schemas/support-policy.schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://bigpixelrocket.dev/schemas/support-policy.schema.json", - "type": "object", - "additionalProperties": false, - "required": ["schemaVersion", "policyInvariantsDigest", "maintainedBranches", "sourceEvidenceDigests", "actionKey", "acceptedAt"], - "properties": { - "schemaVersion": {"const": 1}, - "policyInvariantsDigest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, - "maintainedBranches": {"type": "array", "items": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+$"}, "uniqueItems": true}, - "sourceEvidenceDigests": {"type": "array", "items": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, "uniqueItems": true}, - "actionKey": {"type": "string", "pattern": "^(bootstrap|new_branch:[0-9]+\\.[0-9]+|branch_eol:[0-9]+\\.[0-9]+:[0-9]{4}-[0-9]{2}-[0-9]{2})$"}, - "acceptedAt": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"} - } -} From ae0454a5a27da15eeffc623f85c3739cc75e9a67 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:20:36 +0300 Subject: [PATCH 19/48] refactor: align the completion assessment schema with admission The plan schema's completionAssessment fragment and the standalone assessment schema describe the same document, but the standalone copy let an agent return zero criteria or empty criterion ids. Deterministic admission (validate_completion_assessment) already rejects both, so the schema was strictly looser than the code it feeds. The remaining differences are phase-specific on purpose and stay: the plan fragment fixes the four investigation criterion ids and constrains evidence to plan-internal references, while implementation and repair assessments answer whatever completionCriteria their task contract declares. --- schemas/agent-completion-assessment.schema.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/schemas/agent-completion-assessment.schema.json b/schemas/agent-completion-assessment.schema.json index 4d97656..b7e4c1a 100644 --- a/schemas/agent-completion-assessment.schema.json +++ b/schemas/agent-completion-assessment.schema.json @@ -19,14 +19,15 @@ "phaseStatus": {"type": "string", "enum": ["complete", "blocked", "needs_human"]}, "criteria": { "type": "array", + "minItems": 1, "items": { "type": "object", "additionalProperties": false, "required": ["id", "status", "evidence"], "properties": { - "id": {"type": "string"}, + "id": {"type": "string", "minLength": 1}, "status": {"type": "string", "enum": ["passed", "failed", "unresolved"]}, - "evidence": {"type": "array", "items": {"type": "string"}} + "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}} } } }, From 952ed1538a689e47800433d9603cb4ae75636d12 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:22:33 +0300 Subject: [PATCH 20/48] refactor: dedupe digest and sha validation helpers The evidence manifest digest formula existed twice: capture_evidence wrote it and validate_recaptured_evidence recomputed it from its own copy of the projected fields. A one-sided edit would have made every prior manifest unverifiable, so the projection now lives in manifest_digest(). The 40-hex commit SHA pattern was re-spelled inline at three call sites next to the COMMIT_SHA_RE that already held it. retry_decision and audit_reconstruction stay: the brief allowed deleting them only if nothing outside tests calls them, and autorelease/verify.py does, at checks A06 and A19. $ grep -rn "retry_decision\|audit_reconstruction" --exclude-dir=.git --exclude-dir=.build . autorelease/control.py:... autorelease/verify.py:... tests/test_autorelease.py:... Both now carry a docblock naming that caller, so the next sweep does not have to rediscover it. --- autorelease/control.py | 49 +++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/autorelease/control.py b/autorelease/control.py index f883493..5e9e768 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -127,6 +127,22 @@ def sha256_file(path: pathlib.Path) -> str: return "sha256:" + digest.hexdigest() +def manifest_digest(captures: Iterable[dict[str, Any]]) -> str: + """Digest the identity of an evidence capture set. + + The writer (capture_evidence) and every reader (validate_recaptured_evidence, + the attestation predicate) must agree byte for byte, so the projected fields + and their order live here once. Only captureId, status, and digest are + covered: timestamps and body paths differ between runs that captured + identical evidence. + """ + comparable = [ + {"captureId": item["captureId"], "status": item["status"], "digest": item["digest"]} + for item in captures + ] + return sha256_bytes(canonical_json(comparable)) + + def load_json(path: pathlib.Path) -> Any: try: return json.loads(path.read_text()) @@ -298,7 +314,6 @@ def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str captures = manifest.get("captures") require(isinstance(captures, list), f"{label} evidence captures must be an array") indexed: dict[str, dict[str, Any]] = {} - comparable = [] for capture in captures: require(isinstance(capture, dict), f"{label} evidence capture must be an object") capture_id = capture.get("captureId") @@ -308,10 +323,9 @@ def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str require(capture.get("status") == 200, f"{label} evidence capture is not healthy: {capture_id}") require(bool(SHA256_RE.fullmatch(digest or "")), f"{label} evidence digest is invalid: {capture_id}") indexed[capture_id] = capture - comparable.append({"captureId": capture_id, "status": capture["status"], "digest": digest}) require(set(indexed) == EVIDENCE_CAPTURE_IDS, f"{label} evidence capture set changed") require( - manifest.get("manifestDigest") == sha256_bytes(canonical_json(comparable)), + manifest.get("manifestDigest") == manifest_digest(captures), f"{label} evidence manifest digest mismatch", ) return indexed @@ -751,7 +765,7 @@ def seal_patch( expected_digests = plan["agentContract"]["instructionDigests"] validate_completion_assessment(result, contract, expected_digests) require(result["goNoGo"] == "go", "implementation result is no-go") - require(bool(re.fullmatch(r"[0-9a-f]{40}", base or "")), "base is not an exact commit SHA") + require(bool(COMMIT_SHA_RE.fullmatch(base or "")), "base is not an exact commit SHA") require(git(repo, "rev-parse", f"{base}^{{commit}}").stdout.strip() == base, "base is not an exact commit") paths = changed_paths(repo, base) require(bool(paths), "implementation produced no patch") @@ -845,13 +859,13 @@ def verify_merge( current: dict[str, str], readiness: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - require(bool(re.fullmatch(r"[0-9a-f]{40}", expected_head or "")), "expected head is not an exact commit SHA") + require(bool(COMMIT_SHA_RE.fullmatch(expected_head or "")), "expected head is not an exact commit SHA") actual_head = git(repo, "rev-parse", "HEAD").stdout.strip() require(actual_head == expected_head, "PR head does not equal validated SHA") require(checks and all(value == "success" for value in checks.values()), "required checks did not succeed") require(preconditions == current, "merge preconditions changed") base_sha = manifest.get("baseSha") - require(bool(re.fullmatch(r"[0-9a-f]{40}", base_sha or "")), "sealed manifest has no exact base SHA") + require(bool(COMMIT_SHA_RE.fullmatch(base_sha or "")), "sealed manifest has no exact base SHA") require( git(repo, "rev-list", "--parents", "-n", "1", expected_head).stdout.split() == [expected_head, base_sha], @@ -1153,6 +1167,12 @@ def retry_decision( failure_fingerprint: str, max_attempts: int, ) -> dict[str, Any]: + """Decide whether a failed agent phase may be recalled. + + No workflow calls this: the retry budget is an acceptance property, asserted + by autorelease/verify.py check A06, which proves an identical repeated + failure can never spend an unbounded number of agent runs. + """ require(0 < max_attempts <= 5, "retry budget is outside the reviewed bound") attempts = int(event.get("attemptCount", 0)) previous = event.get("failureFingerprint") @@ -1168,6 +1188,13 @@ def mutation_allowed(operator_state: dict[str, Any]) -> bool: def audit_reconstruction(event: dict[str, Any], root: pathlib.Path) -> dict[str, Any]: + """Replay a completed event from its retained evidence alone. + + No workflow calls this: auditability is an acceptance property, asserted by + autorelease/verify.py check A19, which proves a finished action can be + reconstructed from the record and rejects it once any cited file is missing + or altered. + """ required = event.get("auditEvidence", []) require(isinstance(required, list) and bool(required), "event has no audit evidence") verified = [] @@ -1292,15 +1319,7 @@ def capture_evidence( "captures": captures, "manifestDigest": "", } - comparable = [ - { - "captureId": item["captureId"], - "status": item["status"], - "digest": item["digest"], - } - for item in captures - ] - manifest["manifestDigest"] = sha256_bytes(canonical_json(comparable)) + manifest["manifestDigest"] = manifest_digest(captures) write_json(output_dir / "evidence-manifest.json", manifest) return manifest From 47f112350e7133f3c174e9a490676d71aaec8a75 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:24:59 +0300 Subject: [PATCH 21/48] chore: enforce bash defaults and least privilege in workflows Every workflow now declares `defaults: run: shell: bash`. GitHub's implicit shell is `bash -e {0}`, which does not set pipefail, so a failing producer in a pipeline was silently reported as success. Each pipeline was walked before the switch: the shasum-into-awk substitutions and the gh-content into base64 decodes only get stricter, and the one place that deliberately inspects a producer's status (implement.yml's authoritative checks) already reads PIPESTATUS under `set +e`. Job-level `permissions:` blocks exist only to narrow the workflow-level grant. Four re-declared it exactly (e2e agent-canary, implement validate, repair, and validate-repair), so they read as intent while changing nothing. Publish's preflight had the opposite problem: it validates dispatch inputs and never checks out, but silently inherited the release job's write-everything grant. It is now `contents: read`. The reviewed autorelease-e2e.yml digest in .github/autorelease-pins.json moves with the file, which is the pin's whole purpose. --- .github/autorelease-pins.json | 2 +- .github/workflows/autorelease-e2e.yml | 6 ++++-- .github/workflows/autorelease-implement.yml | 13 ++++--------- .github/workflows/autorelease-publish.yml | 6 ++++++ .github/workflows/autorelease-watch.yml | 4 ++++ .github/workflows/build.yml | 4 ++++ .github/workflows/ci.yml | 4 ++++ .github/workflows/protected-controls.yml | 4 ++++ 8 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/autorelease-pins.json b/.github/autorelease-pins.json index e48cacf..b8bd8db 100644 --- a/.github/autorelease-pins.json +++ b/.github/autorelease-pins.json @@ -10,6 +10,6 @@ "openai/codex-action": "52fe01ec70a42f454c9d2ebd47598f9fd6893d56" }, "workflows": { - ".github/workflows/autorelease-e2e.yml": "sha256:677ad87c8c58bdb61e6fc8e54782b70a89533ac4916827414a7d639985949c6b" + ".github/workflows/autorelease-e2e.yml": "sha256:5ae830a817f55657a6583dd3ed121c1156de1ce843a23e0b7ed8fa56a0f0f274" } } diff --git a/.github/workflows/autorelease-e2e.yml b/.github/workflows/autorelease-e2e.yml index e33e8b4..1c5d6e2 100644 --- a/.github/workflows/autorelease-e2e.yml +++ b/.github/workflows/autorelease-e2e.yml @@ -29,6 +29,10 @@ concurrency: group: autorelease-e2e-${{ inputs.suite }} cancel-in-progress: false +defaults: + run: + shell: bash + jobs: # Dispatch inputs select the refs every suite checks out. They are shaped once # here, ahead of every other job, and republished as outputs so no raw inputs @@ -138,8 +142,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 environment: php-autorelease-canary - permissions: - contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index be4bf53..1bd2f72 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -22,6 +22,10 @@ permissions: contents: read actions: read +defaults: + run: + shell: bash + jobs: # Dispatch inputs reach actions/checkout and several run scripts. They are # validated once here, ahead of every other job, and republished as outputs so @@ -145,9 +149,6 @@ jobs: passed: ${{ steps.checks.outputs.passed }} runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: read - actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -211,9 +212,6 @@ jobs: if: needs.validate.outputs.passed != 'true' runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: read - actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -299,9 +297,6 @@ jobs: needs: [preflight, repair] runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: read - actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/autorelease-publish.yml b/.github/workflows/autorelease-publish.yml index 2cbc8a4..fbe5c13 100644 --- a/.github/workflows/autorelease-publish.yml +++ b/.github/workflows/autorelease-publish.yml @@ -32,6 +32,10 @@ concurrency: group: autorelease-publish-${{ inputs.version }} cancel-in-progress: false +defaults: + run: + shell: bash + jobs: # Dispatch inputs reach actions/checkout and many run scripts. They are shaped # once here, ahead of every other job, and republished as outputs so no raw @@ -40,6 +44,8 @@ jobs: name: Validate dispatch inputs runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read outputs: version: ${{ steps.validated.outputs.version }} exact_commit: ${{ steps.validated.outputs.exact_commit }} diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index cdfccef..9d454ba 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -12,6 +12,10 @@ concurrency: group: php-autorelease-watcher cancel-in-progress: false +defaults: + run: + shell: bash + jobs: investigate: name: Capture and investigate diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5f49fa..2b99c98 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,10 @@ concurrency: group: php-spike-${{ github.ref }}-${{ inputs.php_version || '8.4' }}-${{ inputs.stage || 's4' }} cancel-in-progress: true +defaults: + run: + shell: bash + jobs: build: name: PHP ${{ inputs.php_version || '8.4' }} ${{ inputs.stage || 's4' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f355c5f..806310b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: scripts: name: Script checks diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index a6f5b89..c3911a7 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -17,6 +17,10 @@ permissions: contents: read pull-requests: read +defaults: + run: + shell: bash + jobs: protected-controls: name: Protected controls From 4075fbff945e5acd5e969c5d7df946f2689f986f Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:26:17 +0300 Subject: [PATCH 22/48] fix: bind trusted automation exemptions to single-file records The two exemptions that let a bot merge a protected control path without a human review checked only the protected subset of the diff. A PR that changed autorelease-state/last-evidence.json plus any unprotected file still matched, because the unprotected file never entered `protected`. Both writers commit exactly one path onto a branch cut fresh from origin/main (watch.yml's evidence and recovery records, publish.yml's event record), so requiring the whole diff to be that one file costs the trusted path nothing and denies a passenger commit the ride. The inline action-key filename mapping and both branch regexes are unchanged: they are the parts an attacker would want moved. --- .github/workflows/protected-controls.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index c3911a7..2f2aa1f 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -126,9 +126,13 @@ jobs: print("No protected control path changed.") raise SystemExit(0) + # Both trusted-automation exemptions below bind the whole diff, not just + # its protected subset: the watcher writes exactly one file, so any + # unprotected passenger riding along is proof this is not that PR. evidence_run = re.fullmatch(r"autorelease/evidence-(\d+)", head_ref) if ( - protected == ["autorelease-state/last-evidence.json"] + len(files) == 1 + and protected == ["autorelease-state/last-evidence.json"] and evidence_run and author == "github-actions[bot]" and head_repo.lower() == repo.lower() @@ -228,7 +232,8 @@ jobs: event_run = re.fullmatch(r"autorelease/(event|eol-complete)-(\d+)", head_ref) if ( - len(protected) == 1 + len(files) == 1 + and len(protected) == 1 and re.fullmatch(r"autorelease-events/[A-Za-z0-9._-]+\.json", protected[0]) and event_run and author == "github-actions[bot]" From b104cf699cef8baa8d3c7a2575e61538c97ea700 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:27:35 +0300 Subject: [PATCH 23/48] test: fold line continuations before checking gh calls name the repo The assertion that every `gh pr merge|close` in the recovery step passes --repo scanned single lines. A call wrapped after `gh pr` matched nothing, so the loop silently asserted about zero calls, and a call whose --repo sat on a continuation line failed for no reason. Folding continuations first fixes both directions, and pinning the call count keeps a call that disappears entirely from passing by absence. Probed against four shapes: verb wrapped without --repo (previously invisible, now caught), arguments wrapped without --repo, arguments wrapped with --repo on the second line (previously a false failure, now passes), and the single-line form in the file today. --- tests/test_autorelease.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index aa189d6..df9c0e4 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -720,8 +720,13 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): self.assertIn('git worktree remove --force "$worktree"', recovery) self.assertIn('exit "$status"', recovery) # Every gh call here names the repository: without it gh also deletes the local - # branch, which git refuses while the recovery worktree still holds it. - for call in re.findall(r"^\s*gh pr (?:merge|close) .*$", recovery, re.MULTILINE): + # branch, which git refuses while the recovery worktree still holds it. Line + # continuations are folded first, or a call could hide --repo's absence by + # wrapping its arguments onto the next line. + folded = re.sub(r"\\\n\s*", " ", recovery) + calls = re.findall(r"^\s*gh pr\s+(?:merge|close)\s.*$", folded, re.MULTILINE) + self.assertEqual(2, len(calls)) + for call in calls: self.assertIn('--repo "${{ github.repository }}"', call) # A published release downgrades the publish alarm from critical to warning. self.assertIn("release-transaction-state-${{ github.run_id }}", release) From d3df414ace1867de6b3584f2c77785fa59c41a72 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:31:06 +0300 Subject: [PATCH 24/48] docs: record why the recovery withdraw path traps only EXIT Claim under review: the EXIT trap misses SIGTERM, so a cancelled job can orphan the pushed branch and its PR. Probed against bash 5.3 with a fixture holding the same trap shape: trap cleanup EXIT SIGTERM -> cleanup x1, exit 143 trap cleanup EXIT SIGINT -> cleanup x1 trap cleanup EXIT INT TERM SIGTERM -> cleanup x2, exit 0 trap cleanup EXIT INT TERM SIGINT -> cleanup x1 Bash already runs the EXIT trap when the shell dies from a signal, so the withdraw path is armed. Adding INT and TERM would withdraw twice and, worse, report a cancelled step as successful, because the signal trap enters with $? of 0 and hands that to the EXIT trap. The residual gap is SIGKILL after the runner's grace period, which no trap can close. It is accepted: the orphaned branch and PR both carry this run id, so a later run never collides with them, and nothing merges an event record PR on its own. --- .github/workflows/autorelease-watch.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 9d454ba..2762f6e 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -304,6 +304,11 @@ jobs: git branch -D "$branch" || true exit "$status" } + # EXIT alone is deliberate. Bash runs the EXIT trap when the shell is terminated + # by SIGINT or SIGTERM, so a cancelled job still withdraws; adding INT and TERM + # here would run cleanup twice and report the cancelled step as a success. + # SIGKILL leaves the branch and PR behind, which no trap can prevent: both carry + # this run id, so they collide with nothing and wait for an operator. trap cleanup EXIT git worktree add -B "$branch" "$worktree" HEAD cp autorelease-plan-download/recovered-event.json "$worktree/$event" From 3cf020c939a03ceaa94f83b7ab55358868b03bba Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:35:05 +0300 Subject: [PATCH 25/48] chore: scope the public-language check to tracked files The check ran ripgrep when it was installed and grep otherwise, and the two branches did not look at the same files: ripgrep honoured ignore rules and skipped nothing hidden but .git, while grep read build output and every untracked local file. Which tool a runner happened to have installed decided what the gate covered. git ls-files is one scope on every machine. Untracked files are no longer scanned, which is the intended trade: CI checks out a clean tree, and a new file joins the gate when it joins the repository. xargs returns 123 when any grep batch matches nothing, so the finding is read from the output instead of the exit status. This file is in mise-php/autorelease/shared-files.json and lands byte for byte identical in both repositories. --- 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 bec6c5ee8aa3ee13ffcac2379bf7563271ae48db Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:35:23 +0300 Subject: [PATCH 26/48] chore: keep the packaging test artifacts out of the working tree scripts/test.sh packaged a fixture into $PROJECT_ROOT/.artifacts and then deleted the three paths it knew about. It runs inside checkouts that autorelease inspects for an exact tree, so anything the cleanup missed reads as an unsealed edit. The output directory is now scratch space, removed by a trap rather than by name. package.sh keeps .artifacts as its default because the build and release workflows read it from the working tree on purpose. --- scripts/package.sh | 4 +++- scripts/test.sh | 16 +++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/package.sh b/scripts/package.sh index 5726c4a..ea1b2ae 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -31,7 +31,9 @@ if [[ "$ACTUAL_VERSION" != "$PHP_PATCH_VERSION" ]]; then exit 1 fi -ARTIFACT_DIR="$PROJECT_ROOT/.artifacts" +# The build and release workflows read .artifacts from the working tree, so that +# stays the default; the override exists for callers that must not write there. +ARTIFACT_DIR="${ARTIFACT_DIR:-$PROJECT_ROOT/.artifacts}" ARTIFACT_NAME="php-${RELEASE_TAG}-cli-macos-aarch64.tar.gz" TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/php-bin-package.XXXXXX")" trap 'rm -rf "$TEMP_DIR"' EXIT diff --git a/scripts/test.sh b/scripts/test.sh index e0eb1f9..f9e17bf 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -26,15 +26,17 @@ if "$SCRIPT_DIR/compare-modules.sh" \ exit 1 fi +# The packaging check runs inside checkouts that autorelease then inspects for +# an exact tree, so its output goes to scratch space instead of the working +# tree, where a leftover file would read as an unsealed edit. +ARTIFACT_DIR="${RUNNER_TEMP:-$(mktemp -d)}/php-bin-test-artifacts" +export ARTIFACT_DIR +trap 'rm -rf "$ARTIFACT_DIR"' EXIT + "$SCRIPT_DIR/package.sh" "$PROJECT_ROOT/tests/fixtures/php" 8.4.99 -tar -tzf "$PROJECT_ROOT/.artifacts/php-8.4.99-cli-macos-aarch64.tar.gz" \ +tar -tzf "$ARTIFACT_DIR/php-8.4.99-cli-macos-aarch64.tar.gz" \ | grep -Eq '^\./bin/php$' -grep -Fq 'php-8.4.99-cli-macos-aarch64.tar.gz' \ - "$PROJECT_ROOT/.artifacts/SHA256SUMS" -rm -f \ - "$PROJECT_ROOT/.artifacts/php-8.4.99-cli-macos-aarch64.tar.gz" \ - "$PROJECT_ROOT/.artifacts/SHA256SUMS" -rmdir "$PROJECT_ROOT/.artifacts" 2>/dev/null || true +grep -Fq 'php-8.4.99-cli-macos-aarch64.tar.gz' "$ARTIFACT_DIR/SHA256SUMS" ( cd "$PROJECT_ROOT" From 8dc474bc1590082e4e70577b62933d36da2f2291 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:35:23 +0300 Subject: [PATCH 27/48] refactor: reuse canonical digest helpers in the admin snapshot The snapshot reimplemented canonical JSON and the sha256 prefix that autorelease/control.py already defines. Two spellings of one digest formula is one edit away from a snapshot nobody can verify against the control plane. Verified the replacement produces identical bytes for the same input. --- scripts/snapshot-github-admin-state | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/snapshot-github-admin-state b/scripts/snapshot-github-admin-state index 4d5478f..25f89f3 100755 --- a/scripts/snapshot-github-admin-state +++ b/scripts/snapshot-github-admin-state @@ -3,7 +3,6 @@ import argparse import datetime as dt -import hashlib import json import pathlib import subprocess @@ -11,6 +10,11 @@ import sys from typing import Any +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from autorelease.control import canonical_json, sha256_bytes # noqa: E402 + + def gh_api(endpoint: str, allow_missing: bool = False) -> list[Any]: result = subprocess.run( ["gh", "api", endpoint, "--paginate", "--slurp"], @@ -53,11 +57,6 @@ def paginated_items(pages: list[Any], key: str | None = None) -> list[Any]: return items -def digest(value: dict[str, Any]) -> str: - body = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() - return "sha256:" + hashlib.sha256(body).hexdigest() - - parser = argparse.ArgumentParser() parser.add_argument("--repo", required=True) parser.add_argument("--output", type=pathlib.Path, required=True) @@ -132,7 +131,7 @@ try: item["name"] for item in paginated_items(gh_api(f"repos/{args.repo}/labels")) ), } - snapshot["snapshotDigest"] = digest(snapshot) + snapshot["snapshotDigest"] = sha256_bytes(canonical_json(snapshot)) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n") print(json.dumps({"repository": args.repo, "output": str(args.output), "digest": snapshot["snapshotDigest"]})) From abd64ccfa95325eac6f55976d0d5e244bd419e1a Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:35:23 +0300 Subject: [PATCH 28/48] fix: shut the release fixture server down cleanly serve_forever() ran with no handler, so the release job's kill left the process to die on the default disposition: exit 143, no server_close, and the listening socket released only when the process was reaped. SIGTERM and SIGINT now stop the loop and close the server. shutdown() waits for serve_forever() to return and the handler runs on that very thread, so it is called from another one. Probed three times: the process exits 0 and the port is immediately reusable. --- scripts/serve-autorelease-artifact | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/serve-autorelease-artifact b/scripts/serve-autorelease-artifact index acc674a..ac3d9bf 100755 --- a/scripts/serve-autorelease-artifact +++ b/scripts/serve-autorelease-artifact @@ -4,6 +4,8 @@ import argparse import json import pathlib +import signal +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse @@ -66,4 +68,19 @@ class Handler(BaseHTTPRequestHandler): return -ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever() +server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + + +def stop(signal_number: int, frame: object) -> None: + """Release the port on the release job's kill instead of dying mid-request. + + shutdown() blocks until serve_forever() returns, and this handler runs on the + thread inside it, so the request must be made from another thread. + """ + threading.Thread(target=server.shutdown, daemon=True).start() + + +signal.signal(signal.SIGTERM, stop) +signal.signal(signal.SIGINT, stop) +server.serve_forever() +server.server_close() From 723dc81618aca5c9c26be69c0d4e478349725a99 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:35:23 +0300 Subject: [PATCH 29/48] chore: narrow the shellcheck suppression and cover every bash script lib.sh disabled SC2034 for the whole file, which would have hidden the next unused variable too. Only PROJECT_ROOT is unused within the file, since the scripts that source it read it, so the disable sits on that line. CI checked scripts/*.sh and silently skipped the two extensionless bash scripts. They are named explicitly because every other extensionless script under scripts/ is Python, which shellcheck cannot parse. Both were already clean at -S warning. --- .github/workflows/ci.yml | 4 +++- scripts/lib.sh | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 806310b..0a32710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: - name: Install shellcheck run: sudo apt-get update && sudo apt-get install --yes shellcheck - name: Check shell scripts - run: shellcheck scripts/*.sh + # The extensionless bash scripts are named one by one: every other + # extensionless script under scripts/ is Python, which shellcheck cannot read. + run: shellcheck scripts/*.sh scripts/assert-admission-checks scripts/dispatch-pr-checks - name: Run contract tests run: scripts/test.sh diff --git a/scripts/lib.sh b/scripts/lib.sh index dff77ba..6a0e31a 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# shellcheck disable=SC2034 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Read by the scripts that source this file, not by this file. +# shellcheck disable=SC2034 PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" require_macos_arm64() { From ea543dece8b28ef9f4f3e41a88ef45b2723182cb Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 17:38:09 +0300 Subject: [PATCH 30/48] test: assert both repositories name event records identically php-bin files an event record under a name derived from the action key and mise-php reads that record back by the same derivation. Nothing compared the two mappings, so a one-sided edit would have left one repository waiting forever on a file the other never wrote, and no check would have said so. A09 already covers cross-repository coordination, so the comparison lives there. It runs mise-php's own entry point over every action key form both alphabets admit, rather than diffing source text, so a differently written mapping that behaves identically still passes. The single asymmetry is asserted too: mise-php's alphabet excludes no_change on purpose, because a quiet run files no record for it to read. Probed by changing mise-php's separator to "_": A09 fails with "mise-php names new_patch:8.5.9 new_patch_8.5.9.json, php-bin names it new_patch-8.5.9.json". mise-php was restored. --- autorelease/verify.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/autorelease/verify.py b/autorelease/verify.py index 7299cce..89a32eb 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -20,6 +20,7 @@ from autorelease.control import ( ControlError, + action_filename, audit_reconstruction, canonical_json, instruction_digest, @@ -563,8 +564,43 @@ def a09(self, directory: pathlib.Path) -> list[str]: {"ready": True, "commit": "b" * 40, "repo": "mise-php"}, ] result = verify_merge(repo, head, manifest, checks, preconditions, preconditions, readiness) + # php-bin files an event record under a name derived from the action key and + # mise-php reads that record back by the same derivation. A disagreement on any + # key form leaves one repository waiting on a file the other never wrote. This + # goes through mise-php's own entry point rather than its source text, so a + # differently written mapping that behaves identically still passes. + # One fixture per form both alphabets admit; a new form belongs here. + action_keys = [ + "new_patch:8.5.9", + "new_branch:8.6", + "branch_eol:8.2:2026-12-31", + "recipe_rebuild:8.5.9:2", + "repair:8.5.9:deadbeef", + "source_unhealthy:deadbeef", + "health_failed:deadbeef", + "policy_failure:deadbeef", + "auth_failure:deadbeef", + ] + for action_key in action_keys: + mise_name = run( + "./scripts/consume-php-policy", "action-filename", action_key, cwd=self.mise_root + ).stdout.strip() + assert_true( + mise_name == action_filename(action_key), + f"mise-php names {action_key} {mise_name}, php-bin names it {action_filename(action_key)}", + ) + # The one asymmetry is deliberate: a quiet run files no event record, so mise-php + # refuses to name a file for it rather than inventing one it will never read. + quiet = run( + "./scripts/consume-php-policy", "action-filename", "no_change:0123456789abcdef", + cwd=self.mise_root, check=False, + ) + assert_true(quiet.returncode != 0, "mise-php names a record file for a quiet run") (directory / "coordination.json").write_bytes(canonical_json(result)) - return ["coordination.json"] + (directory / "action-filenames.json").write_bytes( + canonical_json({key: action_filename(key) for key in action_keys}) + ) + return ["coordination.json", "action-filenames.json"] def a10(self, directory: pathlib.Path) -> list[str]: releases = (self.mise_root / "lib/releases.lua").read_text() From 8f5baf67413070a1781f9bb318cd1c9828813376 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:15:55 +0300 Subject: [PATCH 31/48] fix: shield the recovery withdraw from cancellation escalation A cancelled job is signalled twice: SIGINT, then SIGTERM once the grace window closes. Only EXIT was trapped, so the second signal kept its default disposition and killed the withdraw partway through, leaving exactly the open bot PR and live remote branch the trap exists to prevent. Ignoring INT and TERM inside cleanup gives the withdraw the rest of the grace window. Probed on bash 5.3.9 and 3.2.57, signalling the whole process group as the runner does. Before, with the escalation arriving one second into cleanup: rc=-15 log=['working', 'cleanup-start'] The withdraw never reached its second step. After: rc=-2 log=['working', 'cleanup-start', 'cleanup-mid', 'cleanup-end'] Cleanup runs once, finishes, and bash re-raises the signal afterwards so the cancelled exit code survives. A plain failure exit is unchanged at rc 7 with the full cleanup log in both variants. --- .github/workflows/autorelease-watch.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 2762f6e..834af57 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -294,6 +294,13 @@ jobs: # rejected as a non-fast-forward. Cleanup never masks the original exit code. cleanup() { local status=$? + # A cancelled job is signalled twice: SIGINT, then SIGTERM after the grace + # window. Only EXIT is trapped, so the second signal keeps its default + # disposition and kills the withdraw partway through, which is how an open + # bot PR and a live remote branch are left behind. Ignoring both here buys + # the withdraw the rest of the grace window. Bash re-raises the signal once + # the trap returns, so the cancelled exit code still survives. + trap '' INT TERM if [[ -n "$number" ]]; then gh pr close "$number" --repo "${{ github.repository }}" --delete-branch || true fi @@ -304,11 +311,12 @@ jobs: git branch -D "$branch" || true exit "$status" } - # EXIT alone is deliberate. Bash runs the EXIT trap when the shell is terminated - # by SIGINT or SIGTERM, so a cancelled job still withdraws; adding INT and TERM - # here would run cleanup twice and report the cancelled step as a success. - # SIGKILL leaves the branch and PR behind, which no trap can prevent: both carry - # this run id, so they collide with nothing and wait for an operator. + # EXIT alone: bash runs it when the shell is terminated by SIGINT or SIGTERM + # too, so one trap covers both a failure and a cancellation, and cleanup cannot + # run twice. It shields itself from the escalation, so the withdraw finishes + # unless the grace window runs out and SIGKILL arrives. That last case no trap + # can cover: the branch and PR carry this run id, so they collide with nothing + # and wait for an operator. trap cleanup EXIT git worktree add -B "$branch" "$worktree" HEAD cp autorelease-plan-download/recovered-event.json "$worktree/$event" From 9ef907121391ddc2254d5f9e913d2b5bea509ac8 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:17:33 +0300 Subject: [PATCH 32/48] 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 mise-php'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 49092acf4558ef22a2cc3695f511127f58368748 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:39:21 +0300 Subject: [PATCH 33/48] refactor: split control module behind stable facade control.py had grown to ~1,470 lines across six unrelated concerns. The code moves verbatim into four private modules and control.py becomes the facade the workflows, scripts, verifier, and tests already import, so every existing public name is still importable as autorelease.control.. The file is both a script and a package module, so direct execution (./autorelease/control.py ) borrows the same repository-root sys.path shim the scripts use; relative imports would have broken it and bare imports would have broken the package consumers. Two lines are not verbatim: capture_evidence now takes its source set instead of defaulting to it, and main() supplies EVIDENCE_SOURCES. The registry of authoritative sources is a reviewed decision, so it stays in the control surface that verify.py check A11 reads, while the fetch client moves out. --- autorelease/_admission.py | 592 +++++++++++++++ autorelease/_evidence.py | 292 ++++++++ autorelease/_state.py | 391 ++++++++++ autorelease/_validation.py | 159 ++++ autorelease/control.py | 1421 +++--------------------------------- 5 files changed, 1536 insertions(+), 1319 deletions(-) create mode 100644 autorelease/_admission.py create mode 100644 autorelease/_evidence.py create mode 100644 autorelease/_state.py create mode 100644 autorelease/_validation.py diff --git a/autorelease/_admission.py b/autorelease/_admission.py new file mode 100644 index 0000000..d678a87 --- /dev/null +++ b/autorelease/_admission.py @@ -0,0 +1,592 @@ +"""Admission of agent work: the plan, the sealed patch, and the merge. + +These are the three gates a model-authored change passes before it can reach a +protected branch. Each one re-asserts the reviewed bounds from the artefacts in +front of it rather than trusting the phase that produced them. +""" + +from __future__ import annotations + +import datetime as dt +import fnmatch +import json +import pathlib +import re +import subprocess +from typing import Any + +from ._evidence import load_plan_evidence +from ._validation import ( + ACTION_KEY_RE, + COMMIT_SHA_RE, + COMPLETION_EVIDENCE_REF_RE, + ROOT, + SECRET_PATTERNS, + SHA256_RE, + STABLE_VERSION_RE, + ControlError, + canonical_json, + instruction_digest, + load_json, + path_is_allowed, + path_is_protected, + require, + resolve_json_pointer, + sha256_bytes, + sha256_file, + utc_now, + write_json, +) + + +REQUIRED_PLAN_CHECKS = ["Script checks"] +PROHIBITED_AGENT_AUTHORITY = { + "merge", + "push", + "tag", + "release", + "publish", + "delete_release", + "overwrite_asset", + "workflow_permissions", + "secret_access", +} + + +def validate_task_contract(contract: dict[str, Any]) -> None: + require(contract.get("contractVersion") == 1, "unsupported task contract version") + require( + contract.get("phase") in {"investigation", "implementation", "repair"}, + "invalid phase", + ) + for field in ( + "goal", + "actionKey", + "preconditions", + "allowedAuthority", + "nonGoals", + "completionCriteria", + "stopConditions", + ): + require(field in contract, f"task contract is missing {field}") + require(bool(contract["goal"]), "phase goal is empty") + require( + isinstance(contract["allowedAuthority"], list), + "allowedAuthority must be an array", + ) + require( + all(isinstance(item, str) for item in contract["allowedAuthority"]), + "allowedAuthority must contain only strings", + ) + require( + not (set(contract["allowedAuthority"]) & PROHIBITED_AGENT_AUTHORITY), + "agent contract grants prohibited irreversible authority", + ) + criteria = contract["completionCriteria"] + require(isinstance(criteria, list) and criteria, "completion criteria are empty") + require(all(isinstance(item, dict) for item in criteria), "completion criteria must be objects") + ids = [criterion.get("id") for criterion in criteria] + require(all(isinstance(item, str) and item for item in ids), "criterion id is missing") + require(len(ids) == len(set(ids)), "criterion ids are not unique") + for criterion in criteria: + require(bool(criterion.get("requirement")), "criterion requirement is missing") + require( + bool(criterion.get("evidenceRequired")), + "criterion evidence requirement is missing", + ) + + +def validate_completion_assessment( + assessment: dict[str, Any], + contract: dict[str, Any], + expected_digests: dict[str, str] | None = None, +) -> None: + validate_task_contract(contract) + require(assessment.get("contractVersion") == 1, "unsupported assessment version") + if expected_digests is not None: + require( + assessment.get("instructionDigests") == expected_digests, + "assessment instruction digests do not match admitted inputs", + ) + status = assessment.get("phaseStatus") + require(status in {"complete", "blocked", "needs_human"}, "invalid phaseStatus") + require(assessment.get("goNoGo") in {"go", "no_go"}, "invalid goNoGo") + expected_ids = { + criterion["id"] for criterion in contract["completionCriteria"] + } + results = assessment.get("criteria") + require(isinstance(results, list), "assessment criteria must be an array") + result_ids = [result.get("id") for result in results] + require(len(result_ids) == len(set(result_ids)), "duplicate criterion result") + require(set(result_ids) == expected_ids, "criterion results are missing or unexpected") + for result in results: + require( + result.get("status") in {"passed", "failed", "unresolved"}, + f"invalid result for {result.get('id')}", + ) + evidence = result.get("evidence") + require(isinstance(evidence, list), "criterion evidence must be an array") + if result["status"] == "passed": + require(bool(evidence), f"passed criterion {result['id']} has no evidence") + unresolved = assessment.get("unresolved") + require(isinstance(unresolved, list), "unresolved must be an array") + mechanically_go = ( + status == "complete" + and all(result["status"] == "passed" for result in results) + and not unresolved + ) + require( + (assessment["goNoGo"] == "go") == mechanically_go, + "go/no-go is inconsistent with criterion results", + ) + + +def validate_stable_release_evidence( + action: str, + release_intent: dict[str, Any] | None, + resolved_evidence: list[dict[str, Any]], +) -> None: + if action not in {"new_patch", "new_branch"}: + return + require(isinstance(release_intent, dict), "stable release action has no release intent") + version = release_intent.get("version") + require( + any( + item.get("captureId") == "php_release_feed" and item.get("value") == version + for item in resolved_evidence + ), + "stable release version is not exact evidence in the official PHP release feed", + ) + + +def _validate_support_policy_document( + policy: Any, + invariants_path: pathlib.Path, +) -> tuple[list[str], list[str]]: + require(isinstance(policy, dict), "support policy must be an object") + require( + set(policy) + == { + "schemaVersion", + "policyInvariantsDigest", + "maintainedBranches", + "sourceEvidenceDigests", + "actionKey", + "acceptedAt", + }, + "support policy contains unknown or missing fields", + ) + require(policy.get("schemaVersion") == 1, "unsupported support policy version") + require( + policy.get("policyInvariantsDigest") == sha256_file(invariants_path), + "support policy is not bound to reviewed invariants", + ) + branches = policy.get("maintainedBranches") + require( + isinstance(branches, list) + and all(isinstance(value, str) and re.fullmatch(r"\d+\.\d+", value) for value in branches) + and branches == sorted(set(branches), key=lambda value: tuple(map(int, value.split(".")))), + "support policy branches are invalid or non-canonical", + ) + evidence = policy.get("sourceEvidenceDigests") + require( + isinstance(evidence, list) + and all(isinstance(value, str) and SHA256_RE.fullmatch(value) for value in evidence) + and evidence == sorted(set(evidence)), + "support policy contains invalid or non-canonical evidence digests", + ) + try: + accepted_at = dt.datetime.strptime(policy.get("acceptedAt", ""), "%Y-%m-%dT%H:%M:%SZ") + except (TypeError, ValueError): + accepted_at = None + require(accepted_at is not None, "support policy acceptance time is invalid") + return branches, evidence + + +def validate_support_policy(root: pathlib.Path = ROOT) -> dict[str, Any]: + invariants_path = root / "autorelease/policy-invariants.json" + policy_path = root / "support-policy.json" + invariants = load_json(invariants_path) + policy = load_json(policy_path) + require(isinstance(invariants, dict), "policy invariants must be an object") + require( + set(invariants) + == { + "schemaVersion", + "target", + "allowPrereleases", + "historicalExactVersionsRemainInstallable", + "immutablePublishedAssets", + }, + "policy invariants contain unknown or missing fields", + ) + require(invariants.get("schemaVersion") == 1, "unsupported policy invariants version") + require( + invariants.get("target") + == {"os": "macOS", "minimumVersion": "26.0", "architecture": "arm64", "sapi": "cli"}, + "reviewed target invariant changed", + ) + require(invariants.get("allowPrereleases") is False, "prereleases must remain forbidden") + require( + invariants.get("historicalExactVersionsRemainInstallable") is True, + "historical exact installs must remain enabled", + ) + require(invariants.get("immutablePublishedAssets") is True, "published assets must remain immutable") + _branches, evidence = _validate_support_policy_document(policy, invariants_path) + action_key = policy.get("actionKey") + require( + action_key == "bootstrap" + or bool(re.fullmatch(r"(?:new_branch:\d+\.\d+|branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2})", action_key or "")), + "invalid support policy action key", + ) + require(action_key == "bootstrap" or bool(evidence), "accepted support policy lacks evidence") + return { + "valid": True, + "policyDigest": sha256_file(policy_path), + "invariantsDigest": sha256_file(invariants_path), + } + + +def validate_plan( + plan: dict[str, Any], + manifest_path: pathlib.Path, + contract: dict[str, Any], + shared_path: pathlib.Path, + phase_path: pathlib.Path, + event_contract_path: pathlib.Path, + repo_heads: dict[str, str] | None = None, + policy_digest: str | None = None, + completed_actions: set[str] | None = None, +) -> dict[str, Any]: + require(plan.get("schemaVersion") == 1, "unsupported autorelease plan version") + require( + plan.get("action") + in { + "no_change", + "new_patch", + "new_branch", + "branch_eol", + "repair", + "reconcile_partial", + "blocked", + "needs_human", + }, + "invalid autorelease action", + ) + action_key = plan.get("actionKey", "") + require(bool(ACTION_KEY_RE.fullmatch(action_key)), "invalid action key") + if plan.get("action") == "no_change": + manifest_digest = load_json(manifest_path).get("manifestDigest", "") + require( + action_key == f"no_change:{manifest_digest.removeprefix('sha256:')[:16]}", + "no-change action key is not bound to the evidence manifest", + ) + require(plan.get("editsRequired") is False, "no-change plan cannot require edits") + require(not plan.get("releaseIntent"), "no-change plan cannot request a release") + elif plan.get("action") not in {"blocked", "needs_human"}: + require(plan.get("editsRequired") in {True, False}, "plan must declare whether edits are required") + require( + action_key not in (completed_actions or set()), + "action key already completed", + ) + expected_digests = { + "shared": instruction_digest(shared_path), + "phaseTemplate": instruction_digest(phase_path), + "eventContract": instruction_digest(event_contract_path), + } + agent_contract = plan.get("agentContract", {}) + require(agent_contract.get("contractVersion") == 1, "invalid agent contract version") + require( + agent_contract.get("instructionDigests") == expected_digests, + "plan instruction digests do not match supplied instructions", + ) + validate_completion_assessment( + { + **plan.get("completionAssessment", {}), + "contractVersion": 1, + "instructionDigests": expected_digests, + }, + contract, + expected_digests, + ) + if plan["action"] in {"blocked", "needs_human"}: + require( + plan["completionAssessment"]["goNoGo"] == "no_go", + "blocked plans cannot advance", + ) + else: + require( + plan["completionAssessment"]["goNoGo"] == "go", + "only an internally complete agent plan can advance", + ) + declared_heads = plan.get("preconditions", {}) + require(isinstance(declared_heads, dict), "preconditions must be an object") + if repo_heads: + for key, value in repo_heads.items(): + require(declared_heads.get(key) == value, f"stale repository precondition: {key}") + if policy_digest is not None: + require( + declared_heads.get("supportPolicyDigest") == policy_digest, + "stale support policy precondition", + ) + evidence_refs = {} + resolved_evidence = [] + for index, evidence in enumerate(plan.get("evidence", [])): + capture, body = load_plan_evidence(manifest_path, evidence.get("captureId", "")) + require(evidence.get("digest") == capture["digest"], "plan evidence digest mismatch") + locator = evidence.get("locator", {}) + if locator.get("kind") == "json_pointer": + try: + document = json.loads(body) + except json.JSONDecodeError as error: + raise ControlError("JSON locator targets a non-JSON capture") from error + resolved_value = resolve_json_pointer(document, locator.get("value", "")) + elif locator.get("kind") == "text_fragment": + fragment = locator.get("value", "") + require(bool(fragment) and fragment.encode() in body, "text locator does not resolve") + resolved_value = fragment + else: + raise ControlError("unsupported evidence locator") + evidence_refs[f"evidence[{index}]"] = evidence + resolved_evidence.append( + {"captureId": evidence.get("captureId"), "value": resolved_value} + ) + research_sources = plan.get("researchSources", []) + require(isinstance(research_sources, list), "researchSources must be an array") + precondition_refs = {f"preconditions.{key}" for key in declared_heads} + source_refs = {f"researchSources[{index}]" for index in range(len(research_sources))} + for result in plan["completionAssessment"]["criteria"]: + for reference in result["evidence"]: + require( + bool(COMPLETION_EVIDENCE_REF_RE.fullmatch(reference)), + f"invalid criterion evidence reference: {reference}", + ) + require( + reference in evidence_refs + or reference in precondition_refs + or reference in source_refs, + f"criterion evidence reference does not resolve: {reference}", + ) + allowed_paths = plan.get("allowedPaths", {}) + require(isinstance(allowed_paths, dict), "allowedPaths must be an object") + for patterns in allowed_paths.values(): + require(isinstance(patterns, list), "allowed path set must be an array") + for pattern in patterns: + pure = pathlib.PurePosixPath(pattern) + require(not pure.is_absolute() and ".." not in pure.parts, f"unsafe allowed path: {pattern}") + require( + not path_is_protected(pattern), + f"protected path cannot be admitted for runtime editing: {pattern}", + ) + if fnmatch.fnmatch("support-policy.json", pattern): + require(plan.get("risk") == "lifecycle", "support state requires lifecycle risk") + require(plan.get("action") in {"new_branch", "branch_eol"}, "support state requires a lifecycle action") + repositories = plan.get("repositories") + require( + isinstance(repositories, list) + and "php-bin" in repositories + and all(value in {"php-bin", "mise-php"} for value in repositories), + "plan repository authority is invalid", + ) + require(plan.get("requiredChecks") == REQUIRED_PLAN_CHECKS, "required deterministic checks changed") + release_intent = plan.get("releaseIntent") + if release_intent is not None: + require(isinstance(release_intent, dict), "releaseIntent must be an object or null") + version = release_intent.get("version", "") + require(bool(STABLE_VERSION_RE.fullmatch(version)), "release version is not stable") + require( + not re.search(r"(?:alpha|beta|rc|dev)", version, re.I), + "prerelease intent is forbidden", + ) + validate_stable_release_evidence(plan.get("action", ""), release_intent, resolved_evidence) + operations = plan.get("agentOperations") + require(isinstance(operations, list), "agentOperations must be an array") + require(all(isinstance(operation, str) for operation in operations), "agentOperations must contain strings") + for operation in operations: + require(operation not in PROHIBITED_AGENT_AUTHORITY, f"prohibited agent operation: {operation}") + budgets = plan.get("budgets") + require(isinstance(budgets, dict) and bool(budgets), "plan must declare reviewed budgets") + for field, upper, label in ( + ("maxModelCalls", 5, "model-call"), + ("maxRetries", 3, "retry"), + ("timeoutMinutes", 60, "time"), + ): + value = budgets.get(field) + require(isinstance(value, int) and not isinstance(value, bool), f"{field} must be an integer") + require(0 < value <= upper, f"{label} budget is outside reviewed bound") + return { + "admitted": True, + "admittedAt": utc_now(), + "actionKey": action_key, + "planDigest": sha256_bytes(canonical_json(plan)), + "instructionDigests": expected_digests, + } + + +def git(repo: pathlib.Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=repo, + check=check, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def changed_paths(repo: pathlib.Path, base: str) -> list[str]: + result = git(repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, "--") + paths = [line for line in result.stdout.splitlines() if line] + untracked = git(repo, "ls-files", "--others", "--exclude-standard").stdout.splitlines() + return sorted(set(paths + untracked)) + + +def seal_patch( + repo: pathlib.Path, + base: str, + plan: dict[str, Any], + result: dict[str, Any], + contract: dict[str, Any], + output_dir: pathlib.Path, +) -> dict[str, Any]: + expected_digests = plan["agentContract"]["instructionDigests"] + validate_completion_assessment(result, contract, expected_digests) + require(result["goNoGo"] == "go", "implementation result is no-go") + require(bool(COMMIT_SHA_RE.fullmatch(base or "")), "base is not an exact commit SHA") + require(git(repo, "rev-parse", f"{base}^{{commit}}").stdout.strip() == base, "base is not an exact commit") + paths = changed_paths(repo, base) + require(bool(paths), "implementation produced no patch") + admitted = [ + item + for patterns in plan.get("allowedPaths", {}).values() + for item in patterns + ] + for path in paths: + require(not path_is_protected(path), f"patch changes protected path: {path}") + require(path_is_allowed(path, admitted), f"patch changes unadmitted path: {path}") + candidate = repo / path + if candidate.exists(): + require(not candidate.is_symlink(), f"patch contains symlink: {path}") + require(candidate.is_file(), f"patch contains unsupported entry: {path}") + require(candidate.stat().st_size <= 2 * 1024 * 1024, f"patch file too large: {path}") + mode = candidate.stat().st_mode & 0o777 + require(mode in {0o644, 0o755}, f"patch contains unexpected mode: {path}") + require(mode != 0o755 or path.startswith("scripts/"), f"unexpected executable path: {path}") + body = candidate.read_bytes() + require(b"\0" not in body, f"patch contains binary file: {path}") + try: + decoded = body.decode("utf-8") + except UnicodeDecodeError as error: + raise ControlError(f"patch file is not valid UTF-8: {path}") from error + for pattern in SECRET_PATTERNS: + require(not pattern.search(decoded), f"patch contains secret-like material: {path}") + if path == "support-policy.json": + try: + policy = json.loads(decoded) + except json.JSONDecodeError as error: + raise ControlError("support policy is not valid JSON") from error + _branches, policy_evidence = _validate_support_policy_document( + policy, + repo / "autorelease/policy-invariants.json", + ) + evidence_digests = sorted( + {item.get("digest") for item in plan.get("evidence", []) if item.get("digest")} + ) + require( + policy_evidence == evidence_digests and bool(evidence_digests), + "support policy is not bound to admitted captured evidence", + ) + require(policy.get("actionKey") == plan.get("actionKey"), "support policy action key changed") + output_dir.mkdir(parents=True, exist_ok=True) + patch_path = output_dir / "sealed.patch" + tracked_patch = git(repo, "diff", "--binary", "--full-index", base, "--").stdout + untracked_patch_parts = [] + for path in git(repo, "ls-files", "--others", "--exclude-standard").stdout.splitlines(): + proc = subprocess.run( + ["git", "diff", "--binary", "--no-index", "--", "/dev/null", path], + cwd=repo, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + require(proc.returncode in {0, 1}, f"failed to serialize untracked path: {path}") + untracked_patch_parts.append(proc.stdout) + patch_path.write_text(tracked_patch + "".join(untracked_patch_parts)) + require(patch_path.stat().st_size <= 4 * 1024 * 1024, "sealed patch exceeds size limit") + files = [] + for path in paths: + candidate = repo / path + files.append( + { + "path": path, + "digest": sha256_file(candidate) if candidate.is_file() else None, + "mode": oct(candidate.stat().st_mode & 0o777) if candidate.exists() else None, + } + ) + manifest = { + "schemaVersion": 1, + "baseSha": base, + "actionKey": plan["actionKey"], + "planDigest": sha256_bytes(canonical_json(plan)), + "patchDigest": sha256_file(patch_path), + "files": files, + "sealedAt": utc_now(), + } + write_json(output_dir / "patch-manifest.json", manifest) + return manifest + + +def verify_merge( + repo: pathlib.Path, + expected_head: str, + manifest: dict[str, Any], + checks: dict[str, Any], + preconditions: dict[str, str], + current: dict[str, str], + readiness: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + require(bool(COMMIT_SHA_RE.fullmatch(expected_head or "")), "expected head is not an exact commit SHA") + actual_head = git(repo, "rev-parse", "HEAD").stdout.strip() + require(actual_head == expected_head, "PR head does not equal validated SHA") + require(checks and all(value == "success" for value in checks.values()), "required checks did not succeed") + require(preconditions == current, "merge preconditions changed") + base_sha = manifest.get("baseSha") + require(bool(COMMIT_SHA_RE.fullmatch(base_sha or "")), "sealed manifest has no exact base SHA") + require( + git(repo, "rev-list", "--parents", "-n", "1", expected_head).stdout.split() + == [expected_head, base_sha], + "validated commit is not a single commit on the sealed base", + ) + actual_paths = set( + git( + repo, + "diff", + "--name-only", + "--diff-filter=ACDMRTUXB", + base_sha, + expected_head, + "--", + ).stdout.splitlines() + ) + file_records = manifest.get("files", []) + require(isinstance(file_records, list), "sealed manifest files are invalid") + manifest_paths = {item.get("path") for item in file_records if isinstance(item, dict)} + require(len(manifest_paths) == len(file_records) and None not in manifest_paths, "sealed manifest paths are invalid") + require(actual_paths == manifest_paths, "final diff does not equal the sealed manifest") + for file_record in file_records: + path = file_record["path"] + require(not path_is_protected(path), f"sealed manifest contains protected path: {path}") + candidate = repo / path + expected = file_record.get("digest") + require(candidate.is_file() if expected else not candidate.exists(), f"manifest path mismatch: {path}") + if expected: + require(sha256_file(candidate) == expected, f"validated file changed: {path}") + require( + oct(candidate.stat().st_mode & 0o777) == file_record.get("mode"), + f"validated file mode changed: {path}", + ) + for record in readiness or []: + require(record.get("ready") is True, "cross-repository readiness is missing") + require(bool(record.get("commit")), "readiness record has no exact commit") + return {"admitted": True, "headSha": actual_head, "verifiedAt": utc_now()} diff --git a/autorelease/_evidence.py b/autorelease/_evidence.py new file mode 100644 index 0000000..e84888f --- /dev/null +++ b/autorelease/_evidence.py @@ -0,0 +1,292 @@ +"""Evidence capture and the readers that re-derive its identity. + +Captured bodies are opaque bytes: this module fetches them, digests them, and +proves a cited capture still resolves to the same bytes. It deliberately does +not interpret a body, so no source-format parser belongs here. +""" + +from __future__ import annotations + +import pathlib +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any, Iterable + +from ._validation import ( + ACTION_KEY_RE, + COMMIT_SHA_RE, + SHA256_RE, + ControlError, + canonical_json, + contained_path, + load_json, + require, + sha256_bytes, + utc_now, + write_json, +) + + +EVIDENCE_CAPTURE_IDS = { + "php_supported_versions", + "php_release_feed", + "php_source_tags", + "php_bin_releases", + "php_bin_state", + "mise_php_releases", + "mise_php_state", +} +RUNTIME_PLAN_EVIDENCE_IDS = {"evidence_manifest", "watch_decision"} + + +def manifest_digest(captures: Iterable[dict[str, Any]]) -> str: + """Digest the identity of an evidence capture set. + + The writer (capture_evidence) and every reader (validate_recaptured_evidence, + the attestation predicate) must agree byte for byte, so the projected fields + and their order live here once. Only captureId, status, and digest are + covered: timestamps and body paths differ between runs that captured + identical evidence. + """ + comparable = [ + {"captureId": item["captureId"], "status": item["status"], "digest": item["digest"]} + for item in captures + ] + return sha256_bytes(canonical_json(comparable)) + + +def validate_recaptured_evidence( + plan: dict[str, Any], + admitted_manifest: dict[str, Any], + current_manifest: dict[str, Any], +) -> dict[str, Any]: + """Verify cited authoritative captures while allowing runtime-only evidence.""" + + def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str, Any]]: + require(isinstance(manifest, dict), f"{label} evidence manifest must be an object") + require(manifest.get("schemaVersion") == 1, f"{label} evidence manifest version is invalid") + captures = manifest.get("captures") + require(isinstance(captures, list), f"{label} evidence captures must be an array") + indexed: dict[str, dict[str, Any]] = {} + for capture in captures: + require(isinstance(capture, dict), f"{label} evidence capture must be an object") + capture_id = capture.get("captureId") + digest = capture.get("digest") + require(capture_id in EVIDENCE_CAPTURE_IDS, f"{label} evidence capture is unknown") + require(capture_id not in indexed, f"{label} evidence capture is duplicated: {capture_id}") + require(capture.get("status") == 200, f"{label} evidence capture is not healthy: {capture_id}") + require(bool(SHA256_RE.fullmatch(digest or "")), f"{label} evidence digest is invalid: {capture_id}") + indexed[capture_id] = capture + require(set(indexed) == EVIDENCE_CAPTURE_IDS, f"{label} evidence capture set changed") + require( + manifest.get("manifestDigest") == manifest_digest(captures), + f"{label} evidence manifest digest mismatch", + ) + return indexed + + admitted = indexed_captures(admitted_manifest, "admitted") + current = indexed_captures(current_manifest, "current") + evidence = plan.get("evidence") + require(isinstance(evidence, list) and bool(evidence), "autorelease plan has no evidence") + verified = [] + for item in evidence: + require(isinstance(item, dict), "plan evidence entry must be an object") + capture_id = item.get("captureId") + digest = item.get("digest") + require(bool(SHA256_RE.fullmatch(digest or "")), f"plan evidence digest is invalid: {capture_id}") + if capture_id in RUNTIME_PLAN_EVIDENCE_IDS: + continue + require(capture_id in admitted, f"plan evidence capture is unknown: {capture_id}") + require(admitted[capture_id]["digest"] == digest, f"admitted evidence digest mismatch: {capture_id}") + require(current[capture_id]["digest"] == digest, f"recaptured evidence changed: {capture_id}") + verified.append(capture_id) + require(bool(verified), "autorelease plan cites no authoritative captured evidence") + return {"valid": True, "verifiedCaptureIds": sorted(verified)} + + +def validate_evidence_state_record(record: dict[str, Any]) -> None: + require(isinstance(record, dict), "evidence state must be an object") + require( + set(record) == {"schemaVersion", "manifestDigest", "planDigest", "captures"}, + "evidence state fields changed", + ) + require(record.get("schemaVersion") == 1, "invalid evidence state version") + require(bool(SHA256_RE.fullmatch(record.get("manifestDigest", ""))), "invalid evidence manifest digest") + require(bool(SHA256_RE.fullmatch(record.get("planDigest", ""))), "invalid evidence plan digest") + captures = record.get("captures") + require(isinstance(captures, list), "evidence captures must be an array") + capture_ids = [] + for capture in captures: + require(isinstance(capture, dict), "evidence capture must be an object") + require(set(capture) == {"captureId", "digest", "status"}, "evidence capture fields changed") + capture_ids.append(capture.get("captureId")) + require(bool(SHA256_RE.fullmatch(capture.get("digest", ""))), "invalid evidence capture digest") + require(capture.get("status") == 200, "evidence capture status is not healthy") + require(len(capture_ids) == len(set(capture_ids)), "duplicate evidence capture") + require(set(capture_ids) == EVIDENCE_CAPTURE_IDS, "evidence capture set changed") + + +def validate_evidence_attestation_predicate( + predicate: dict[str, Any], + *, + run_id: str, + source_sha: str, + action_key: str, + manifest_digest: str, +) -> None: + require(isinstance(predicate, dict), "evidence attestation predicate must be an object") + require( + set(predicate) == {"schemaVersion", "runId", "sourceSha", "actionKey", "manifestDigest"}, + "evidence attestation predicate fields changed", + ) + require(predicate.get("schemaVersion") == 1, "invalid evidence attestation predicate version") + require(bool(re.fullmatch(r"[1-9][0-9]*", run_id)), "invalid expected watcher run") + require(bool(COMMIT_SHA_RE.fullmatch(source_sha)), "invalid expected watcher source") + require(bool(ACTION_KEY_RE.fullmatch(action_key)), "invalid expected watcher action") + require(bool(SHA256_RE.fullmatch(manifest_digest)), "invalid expected evidence manifest") + require(predicate.get("runId") == run_id, "evidence attestation run mismatch") + require(predicate.get("sourceSha") == source_sha, "evidence attestation source mismatch") + require(predicate.get("actionKey") == action_key, "evidence attestation action mismatch") + require( + predicate.get("manifestDigest") == manifest_digest, + "evidence attestation manifest mismatch", + ) + + +def load_capture(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: + manifest = load_json(manifest_path) + require(isinstance(manifest, dict), "capture manifest must be an object") + captures = manifest.get("captures", []) + require(isinstance(captures, list), "capture manifest captures must be an array") + matches = [item for item in captures if isinstance(item, dict) and item.get("captureId") == capture_id] + require(len(matches) == 1, f"capture {capture_id} does not resolve exactly once") + capture = matches[0] + body_path = contained_path(manifest_path.parent, capture.get("bodyPath"), "capture body path") + require(body_path.is_file(), f"capture body is missing: {body_path}") + body = body_path.read_bytes() + require(sha256_bytes(body) == capture.get("digest"), f"capture digest mismatch: {capture_id}") + return capture, body + + +def load_plan_evidence(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: + if capture_id not in RUNTIME_PLAN_EVIDENCE_IDS: + return load_capture(manifest_path, capture_id) + runtime_root = manifest_path.parent.parent + path = { + "evidence_manifest": manifest_path, + "watch_decision": runtime_root / "watch-decision.json", + }[capture_id] + require(path.is_file(), f"runtime plan evidence is unavailable: {capture_id}") + body = path.read_bytes() + return {"captureId": capture_id, "digest": sha256_bytes(body)}, body + + +class RestrictedRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any: + old = urllib.parse.urlparse(req.full_url) + new = urllib.parse.urlparse(newurl) + if new.scheme != "https" or new.hostname != old.hostname: + raise urllib.error.HTTPError(newurl, code, "cross-host redirect rejected", headers, fp) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +@dataclass(frozen=True) +class EvidenceSource: + capture_id: str + url: str + max_bytes: int + + +def capture_evidence( + output_dir: pathlib.Path, + sources: Iterable[EvidenceSource], + token: str | None = None, +) -> dict[str, Any]: + """Fetch each source once and record what came back, healthy or not. + + The source set is supplied rather than defaulted: which sources are + authoritative is a reviewed decision that stays in `control`, so this client + holds no opinion about where evidence comes from. + """ + output_dir.mkdir(parents=True, exist_ok=True) + opener = urllib.request.build_opener(RestrictedRedirect) + captures = [] + for source in sources: + headers = { + "Accept": "application/vnd.github+json, application/json, text/html", + "User-Agent": "bigpixelrocket-autorelease/1", + } + if token and urllib.parse.urlparse(source.url).hostname == "api.github.com": + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request( + source.url, + headers=headers, + ) + last_error: Exception | None = None + for attempt in range(3): + if attempt: + time.sleep(2**attempt) + try: + with opener.open(request, timeout=30) as response: + body = response.read(source.max_bytes + 1) + require(len(body) <= source.max_bytes, f"capture too large: {source.capture_id}") + body_path = pathlib.Path("raw") / f"{source.capture_id}.body" + destination = output_dir / body_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(body) + captures.append( + { + "captureId": source.capture_id, + "url": source.url, + "retrievedAt": utc_now(), + "status": response.status, + "contentType": response.headers.get("Content-Type"), + "etag": response.headers.get("ETag"), + "lastModified": response.headers.get("Last-Modified"), + "digest": sha256_bytes(body), + "bodyPath": body_path.as_posix(), + } + ) + last_error = None + break + except ControlError as error: + last_error = error + break + except urllib.error.HTTPError as error: + last_error = error + if error.code not in {408, 429} and not 500 <= error.code < 600: + break + except (OSError, urllib.error.URLError) as error: + last_error = error + if last_error is not None: + body_path = pathlib.Path("raw") / f"{source.capture_id}.body" + destination = output_dir / body_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"") + captures.append( + { + "captureId": source.capture_id, + "url": source.url, + "retrievedAt": utc_now(), + "status": 0, + "contentType": None, + "etag": None, + "lastModified": None, + "digest": sha256_bytes(b""), + "bodyPath": body_path.as_posix(), + "error": type(last_error).__name__, + } + ) + manifest = { + "schemaVersion": 1, + "capturedAt": utc_now(), + "captures": captures, + "manifestDigest": "", + } + manifest["manifestDigest"] = manifest_digest(captures) + write_json(output_dir / "evidence-manifest.json", manifest) + return manifest diff --git a/autorelease/_state.py b/autorelease/_state.py new file mode 100644 index 0000000..fad234c --- /dev/null +++ b/autorelease/_state.py @@ -0,0 +1,391 @@ +"""Event, release, and watcher state machines. + +Every legal transition, the one name an action key may occupy, and the single +routing table the watcher follows live here. These functions decide what happens +next from recorded state alone; they never fetch evidence or admit a plan. +""" + +from __future__ import annotations + +import json +import pathlib +import re +from typing import Any, Iterable + +from ._validation import ( + ACTION_KEY_RE, + SHA256_RE, + ControlError, + canonical_json, + contained_path, + require, + sha256_bytes, + sha256_file, + utc_now, +) + + +# A zero patch component is deliberately excluded: `8.6.0` is equally the tag of a +# `new_branch:8.6` action, so its action key is not derivable from the tag alone. +RECOVERABLE_RELEASE_TAG_RE = re.compile(r"^(\d+\.\d+\.[1-9]\d*)(?:-([1-9]\d*))?$") +LEGAL_EVENT_TRANSITIONS = { + "detected": {"php_bin_ready", "blocked", "needs_human"}, + "php_bin_ready": {"mise_ready", "release_requested", "blocked", "needs_human"}, + "mise_ready": {"release_requested", "complete", "blocked", "needs_human"}, + "release_requested": {"released", "blocked", "needs_human"}, + "released": {"public_install_verified", "blocked", "needs_human"}, + "public_install_verified": {"complete", "blocked", "needs_human"}, + "blocked": {"detected", "php_bin_ready", "mise_ready", "release_requested", "needs_human"}, + "needs_human": {"detected", "php_bin_ready", "mise_ready", "release_requested", "blocked"}, + "complete": set(), +} +LEGAL_RELEASE_TRANSITIONS = { + "requested": "built", + "built": "draft_created", + "draft_created": "draft_verified", + "draft_verified": "published", + "published": "public_verified", + "public_verified": "complete", +} + + +def validate_completed_event_record(record: dict[str, Any]) -> None: + """Validate a durable event as a complete, contiguous legal transition history.""" + + require(isinstance(record, dict), "autorelease event must be an object") + require(record.get("schemaVersion") == 1, "autorelease event version is invalid") + require(bool(ACTION_KEY_RE.fullmatch(record.get("actionKey", ""))), "autorelease event action key is invalid") + require(record.get("state") == "complete", "autorelease event is not complete") + history = record.get("history") + require(isinstance(history, list) and bool(history), "autorelease event has no transition history") + current = history[0].get("from") if isinstance(history[0], dict) else None + for transition in history: + require(isinstance(transition, dict), "autorelease event transition must be an object") + require( + set(transition) == {"from", "to", "at", "evidence"}, + "autorelease event transition fields changed", + ) + require(transition.get("from") == current, "autorelease event history is not contiguous") + target = transition.get("to") + require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), "autorelease event transition is illegal") + timestamp = transition.get("at") + require( + isinstance(timestamp, str) and timestamp.endswith("Z"), + "autorelease event transition timestamp is invalid", + ) + evidence = transition.get("evidence") + require( + isinstance(evidence, list) + and bool(evidence) + and all(isinstance(item, dict) and bool(item) for item in evidence), + "autorelease event transition evidence is invalid", + ) + current = target + require(current == record["state"], "autorelease event state does not match its history") + + +def transition_event(event: dict[str, Any], target: str, evidence: list[dict[str, Any]]) -> dict[str, Any]: + current = event.get("state", "detected") + require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), f"illegal event transition: {current} -> {target}") + require(bool(evidence), "event transition requires evidence") + updated = json.loads(json.dumps(event)) + updated["state"] = target + updated.setdefault("history", []).append( + {"from": current, "to": target, "at": utc_now(), "evidence": evidence} + ) + return updated + + +def release_transition( + transaction: dict[str, Any], + target: str, + assets_dir: pathlib.Path, + expected_assets: dict[str, str], +) -> dict[str, Any]: + current = transaction.get("state", "requested") + require(LEGAL_RELEASE_TRANSITIONS.get(current) == target, f"illegal release transition: {current} -> {target}") + published = transaction.get("publishedAssets", {}) + if published: + require(published == expected_assets, "published asset inconsistency") + if target in {"draft_verified", "published", "public_verified", "complete"}: + for name, digest in expected_assets.items(): + path = assets_dir / name + require(path.is_file(), f"release asset is missing: {name}") + require(sha256_file(path) == digest, f"release asset digest mismatch: {name}") + updated = json.loads(json.dumps(transaction)) + updated["state"] = target + updated["assetDigests"] = expected_assets + if target == "published": + updated["publishedAssets"] = expected_assets + updated.setdefault("history", []).append({"from": current, "to": target, "at": utc_now()}) + return updated + + +def notification_decision(event: dict[str, Any], prior: dict[str, Any] | None) -> dict[str, Any]: + fingerprint_fields = { + "state": event.get("state"), + "evidenceDigest": event.get("evidenceDigest"), + "failureFingerprint": event.get("failureFingerprint"), + "humanActionRequired": bool(event.get("humanActionRequired")), + "finalResult": event.get("finalResult"), + } + fingerprint = sha256_bytes(canonical_json(fingerprint_fields)) + if prior and prior.get("fingerprint") == fingerprint: + return {"action": "none", "fingerprint": fingerprint} + if prior is None: + action = "create_and_close" if event.get("state") == "complete" else "create" + elif event.get("state") == "complete": + action = "comment_and_close" + else: + action = "comment" + severity = event.get("severity", "info") + critical = severity == "critical" + return { + "action": action, + "fingerprint": fingerprint, + "critical": critical, + "labels": ["autorelease", *(["attention-required"] if critical or event.get("humanActionRequired") else [])], + } + + +def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any] | None: + """Return a usable retained issue identity without relying on search indexing.""" + issue = (prior or {}).get("issue") + number = issue.get("number") if isinstance(issue, dict) else None + if not isinstance(number, bool) and isinstance(number, int) and number > 0: + return issue + return None + + +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. + + Every event record, readiness record, and automation branch in both repositories is + named from its action key by this one mapping, so the name is only ever derived here. + The key is model-authored and reaches shell arguments and repository paths, so its + alphabet is re-asserted at this boundary rather than trusted from the caller. + """ + require(bool(ACTION_KEY_RE.fullmatch(action_key)), f"invalid action key: {action_key}") + return action_key.translate(ACTION_FILENAME_MAP) + suffix + + +def unrecorded_published_release( + releases: Iterable[dict[str, Any]], + events: Iterable[dict[str, Any]], + record_files: Iterable[str] = (), +) -> str | None: + """Return the action key of one published release that has no event record at all. + + A live release with no record silently corrupts every later decision, because the + completed-action ledger is what admission uses to tell finished work from new work. + Recovery is fail-closed: a release is only claimed when immutability proves it came + from the guarded publish transaction and its action key is derivable from the tag + alone. Any existing record, complete or not, is left to its own path, and so is a + key whose record filename is already occupied by an unrelated document, because the + filer refuses to overwrite a file and would otherwise fail on every later run. One + key is returned per run; a further backlog is repaired by later runs. + """ + recorded = {event.get("actionKey") for event in events} + occupied = set(record_files) + keys = set() + for release in releases: + if not isinstance(release, dict): + continue + if release.get("draft") or release.get("prerelease") or release.get("immutable") is not True: + continue + tag = RECOVERABLE_RELEASE_TAG_RE.fullmatch(str(release.get("tag_name", ""))) + if tag is None: + continue + key = f"recipe_rebuild:{tag.group(1)}:{tag.group(2)}" if tag.group(2) else f"new_patch:{tag.group(1)}" + if key not in recorded and action_filename(key) not in occupied: + keys.add(key) + return min(keys, default=None) + + +def watch_decision( + manifest: dict[str, Any], + previous: dict[str, Any], + events: Iterable[dict[str, Any]], + health: dict[str, Any], + *, + self_evidence_update: bool = False, + releases: Iterable[dict[str, Any]] = (), + record_files: Iterable[str] = (), +) -> dict[str, Any]: + events = list(events) + incomplete = sorted( + event.get("actionKey") + for event in events + if event.get("state") != "complete" + ) + unrecorded = unrecorded_published_release(releases, events, record_files) + if not health.get("healthy", False): + trigger = "health_failed" + elif any(capture.get("status") != 200 for capture in manifest.get("captures", [])): + trigger = "source_unhealthy" + elif incomplete: + trigger = "event_incomplete" + elif previous.get("manifestDigest") != manifest.get("manifestDigest"): + current_captures = { + item.get("captureId"): (item.get("status"), item.get("digest")) + for item in manifest.get("captures", []) + if isinstance(item, dict) + } + previous_captures = { + item.get("captureId"): (item.get("status"), item.get("digest")) + for item in previous.get("captures", []) + if isinstance(item, dict) + } + changed_captures = { + capture_id + for capture_id in set(current_captures) | set(previous_captures) + if current_captures.get(capture_id) != previous_captures.get(capture_id) + } + trigger = ( + "quiet" + if self_evidence_update and changed_captures == {"php_bin_state"} + else "evidence_changed" + ) + else: + trigger = "quiet" + # A missing record outranks every trigger that a trustworthy snapshot can raise, so + # it is repaired before new work starts. It never changes whether the model is + # called: the repair is deterministic, but suppressing the investigation would let a + # blocked repair starve reconciliation and selection on every later run. + model_call = trigger != "quiet" + if unrecorded and trigger not in {"health_failed", "source_unhealthy"}: + trigger = "record_missing" + return { + "schemaVersion": 1, + "trigger": trigger, + "manifestDigest": manifest.get("manifestDigest"), + "incompleteActions": incomplete, + "action": "record_completed_event" if trigger == "record_missing" else "none", + "actionKey": unrecorded if trigger == "record_missing" else "", + "modelCall": model_call, + } + + +# Only these two admitted actions announce themselves before their route runs, and only +# these three select a release for the publish transaction. +WATCH_LIFECYCLE_NOTIFICATION_ACTIONS = frozenset({"new_branch", "branch_eol"}) +# `watch_decision` names a missing event record as its own action. The recovery overlay +# owns that repair, so it is a route the plan never takes rather than an unrouted one. +WATCH_RECOVERY_ACTION = "record_completed_event" +WATCH_PUBLISH_ACTIONS = frozenset({"new_patch", "new_branch", "reconcile_partial"}) + + +def route_watch_action(decision: dict[str, Any]) -> dict[str, Any]: + """Return the one route a coordinated watcher decision takes, or raise. + + The watcher runs two independent routes in the same job: `route` dispatches the + admitted plan, and `recoveryRoute` repairs a published release that has no event + record. Recovery is an overlay rather than an exclusive branch, so it carries its own + field and never competes with the plan for one. + + Every legal combination is enumerated, including the ones that legitimately do + nothing — those return `route: "none"` with the reason, so an idle run stays green. + Anything else raises instead of falling through to a silent success, which is what an + unrouted combination used to do. + """ + action = str(decision.get("action") or "") + action_key = str(decision.get("actionKey") or "") + record_action_key = str(decision.get("recordActionKey") or "") + edits_required = bool(decision.get("editsRequired")) + recovery_merged = bool(decision.get("recoveryMerged")) + evidence_recorded = bool(decision.get("evidenceAlreadyRecorded")) + + # The workflow passes the recovery key separately, but a caller handing this function + # a raw `watch_decision` carries it as that decision's own key, so both are accepted. + recovery_key = record_action_key or (action_key if action == WATCH_RECOVERY_ACTION else "") + + def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: + return { + "schemaVersion": 1, + "route": route, + "reason": reason, + "notify": notify, + "action": action, + "actionKey": action_key, + "recordActionKey": recovery_key, + "recoveryRoute": "recover_record" if recovery_key else "none", + } + + if action in {"", "none"}: + return routed("none", "no_admitted_plan") + if action == WATCH_RECOVERY_ACTION: + return routed("none", "recovery_routed_by_recovery_route") + if recovery_merged and action == "branch_eol": + # The completion asserts an untouched base, which the recovered record just moved. + return routed("none", "eol_completion_deferred_by_recovery") + if action == "no_change" and evidence_recorded: + return routed("none", "evidence_state_already_recorded") + if action in {"blocked", "needs_human"}: + return routed("notify_blocked", "operator_attention_required") + notify = "lifecycle" if action in WATCH_LIFECYCLE_NOTIFICATION_ACTIONS else "none" + if action == "no_change": + return routed("no_change_evidence", "record_reviewed_evidence", notify) + if edits_required: + return routed("dispatch_implementation", "admitted_plan_requires_edits", notify) + if action in WATCH_PUBLISH_ACTIONS: + if record_action_key and action_key == record_action_key: + # The ledger this plan was admitted against is the one missing this record, + # so the release it selects is already public. + return routed("none", "release_published_pending_record", notify) + return routed("dispatch_publish", "publish_admitted_release", notify) + if action == "branch_eol": + return routed("complete_branch_eol", "complete_admitted_eol", notify) + raise ControlError(f"watcher action is unrouted: {action} with editsRequired={edits_required}") + + +def retry_decision( + event: dict[str, Any], + failure_fingerprint: str, + max_attempts: int, +) -> dict[str, Any]: + """Decide whether a failed agent phase may be recalled. + + No workflow calls this: the retry budget is an acceptance property, asserted + by autorelease/verify.py check A06, which proves an identical repeated + failure can never spend an unbounded number of agent runs. + """ + require(0 < max_attempts <= 5, "retry budget is outside the reviewed bound") + attempts = int(event.get("attemptCount", 0)) + previous = event.get("failureFingerprint") + if previous == failure_fingerprint and attempts >= max_attempts: + return {"recallAgent": False, "reason": "identical_failure_exhausted", "attemptCount": attempts} + if previous == failure_fingerprint and event.get("lastRejectionRepeated", False): + return {"recallAgent": False, "reason": "identical_rejection", "attemptCount": attempts} + return {"recallAgent": attempts < max_attempts, "reason": "bounded_retry", "attemptCount": attempts + 1} + + +def mutation_allowed(operator_state: dict[str, Any]) -> bool: + return operator_state.get("unattendedMutation") == "enabled" + + +def audit_reconstruction(event: dict[str, Any], root: pathlib.Path) -> dict[str, Any]: + """Replay a completed event from its retained evidence alone. + + No workflow calls this: auditability is an acceptance property, asserted by + autorelease/verify.py check A19, which proves a finished action can be + reconstructed from the record and rejects it once any cited file is missing + or altered. + """ + required = event.get("auditEvidence", []) + require(isinstance(required, list) and bool(required), "event has no audit evidence") + verified = [] + for item in required: + require(isinstance(item, dict), "audit evidence entry must be an object") + item_path = item.get("path") + item_digest = item.get("digest") + require(isinstance(item_digest, str) and SHA256_RE.fullmatch(item_digest), "audit evidence digest is missing") + path = contained_path(root, item_path, "audit evidence path") + require(path.is_file(), f"audit evidence is unavailable: {item_path}") + require(sha256_file(path) == item_digest, f"audit evidence digest mismatch: {item_path}") + verified.append(item_path) + require(bool(event.get("actionKey")), "audit event has no action key") + require(bool(event.get("history")), "audit event has no transition history") + return {"reconstructed": True, "actionKey": event["actionKey"], "evidence": verified} diff --git a/autorelease/_validation.py b/autorelease/_validation.py new file mode 100644 index 0000000..cac91b6 --- /dev/null +++ b/autorelease/_validation.py @@ -0,0 +1,159 @@ +"""Primitive rejections shared by every deterministic control. + +Digests, canonical JSON, path containment, and the regular expressions that fix +the shape of every identifier live here so that one definition is asserted at +every boundary. Nothing in this module reads state or reaches the network; it is +the bottom of the package and imports no sibling. +""" + +from __future__ import annotations + +import datetime as dt +import fnmatch +import hashlib +import json +import pathlib +import re +import tarfile +from typing import Any, Iterable + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +ACTION_KEY_RE = re.compile( + r"^(no_change:[0-9a-f]{16}|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})$" +) +COMPLETION_EVIDENCE_REF_RE = re.compile( + r"^(evidence\[\d+\]|preconditions\.(?:phpBinHead|misePhpHead|supportPolicyDigest)|" + r"researchSources\[\d+\])$" +) +STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") +PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") +try: + PROTECTED_PATTERNS = tuple(json.loads(PROTECTED_PATHS.read_text())["patterns"]) +except (OSError, KeyError, TypeError, json.JSONDecodeError) as error: + raise RuntimeError(f"cannot load protected paths: {error}") from error +if not all(isinstance(pattern, str) and pattern for pattern in PROTECTED_PATTERNS): + raise RuntimeError("protected paths must be non-empty strings") +SECRET_PATTERNS = ( + re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), + re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), + re.compile(r"\bgh[opusr]_[A-Za-z0-9]{30,}\b"), + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), +) + + +class ControlError(RuntimeError): + """A fail-closed deterministic-control rejection.""" + + +def utc_now() -> str: + return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def canonical_json(value: Any) -> bytes: + return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() + + +def sha256_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return "sha256:" + digest.hexdigest() + + +def load_json(path: pathlib.Path) -> Any: + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ControlError(f"cannot load JSON {path}: {error}") from error + + +def write_json(path: pathlib.Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_bytes(canonical_json(value)) + temporary.replace(path) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ControlError(message) + + +def contained_path(root: pathlib.Path, value: Any, label: str) -> pathlib.Path: + require(isinstance(value, str) and bool(value), f"{label} is missing") + relative = pathlib.PurePosixPath(value) + require(not relative.is_absolute() and ".." not in relative.parts, f"unsafe {label}: {value}") + resolved_root = root.resolve() + resolved = (resolved_root / pathlib.Path(*relative.parts)).resolve() + require(resolved.is_relative_to(resolved_root), f"unsafe {label}: {value}") + return resolved + + +def instruction_digest(path: pathlib.Path) -> str: + require(path.is_file(), f"instruction file does not exist: {path}") + return sha256_file(path) + + +def resolve_json_pointer(document: Any, pointer: str) -> Any: + if pointer == "": + return document + require(pointer.startswith("/"), f"invalid JSON pointer: {pointer}") + current = document + for token in pointer[1:].split("/"): + key = token.replace("~1", "/").replace("~0", "~") + if isinstance(current, list): + require(key.isdigit(), f"non-numeric array index in pointer: {pointer}") + index = int(key) + require(index < len(current), f"array index does not resolve: {pointer}") + current = current[index] + else: + require(isinstance(current, dict) and key in current, f"pointer does not resolve: {pointer}") + current = current[key] + return current + + +def path_is_protected(path: str) -> bool: + normalized = pathlib.PurePosixPath(path).as_posix() + return any(fnmatch.fnmatch(normalized, pattern) for pattern in PROTECTED_PATTERNS) + + +def path_is_allowed(path: str, patterns: Iterable[str]) -> bool: + normalized = pathlib.PurePosixPath(path).as_posix() + return any(fnmatch.fnmatch(normalized, pattern) for pattern in patterns) + + +def _archive_member_name(name: str) -> str: + return name[2:] if name.startswith("./") else name + + +def validate_archive(archive: pathlib.Path, version: str) -> None: + require(archive.name == f"php-{version}-cli-macos-aarch64.tar.gz", "unexpected archive name") + try: + with tarfile.open(archive, "r:gz") as handle: + members = handle.getmembers() + except tarfile.TarError as error: + raise ControlError(f"cannot read archive {archive}: {error}") from error + names = set() + for member in members: + normalized = pathlib.PurePosixPath(_archive_member_name(member.name)) + require( + ".." not in normalized.parts + and not normalized.is_absolute() + and not member.name.startswith("/"), + f"unsafe archive path: {member.name}", + ) + require(not member.issym() and not member.islnk(), "archive contains a link") + names.add(normalized.as_posix()) + require("bin/php" in names, "archive does not contain bin/php") diff --git a/autorelease/control.py b/autorelease/control.py index 5e9e768..285fcbc 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -4,1230 +4,117 @@ This module deliberately does not classify PHP releases or lifecycle state. It validates authority, evidence, state transitions, and immutable effects selected by Codex. + +It is the stable import surface for the package behind it, so every name the +workflows, scripts, verifier, and tests already use stays importable from here: + +- `_validation` — digests, canonical JSON, path containment, and the regular + expressions that fix the shape of every identifier. +- `_evidence` — the opaque capture client and the readers that re-derive a + cited capture's identity. +- `_state` — the event, release, and watcher state machines, including the one + routing table the watcher follows. +- `_admission` — the three gates model-authored work passes: the plan, the + sealed patch, and the merge. """ from __future__ import annotations import argparse -import datetime as dt -import fnmatch -import hashlib import json import os import pathlib -import re -import shutil import subprocess import sys -import tarfile -import tempfile -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from typing import Any, Iterable - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") -COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") -ACTION_KEY_RE = re.compile( - r"^(no_change:[0-9a-f]{16}|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})$" +# Workflows run this file directly (`./autorelease/control.py `), where only +# the autorelease directory is on the import path, while the scripts, verify.py, and +# the tests import it as `autorelease.control`. Direct execution therefore borrows the +# same repository-root shim the scripts use, so the absolute imports below resolve in +# both contexts and no consumer has to know which one it is in. +if __package__ in {None, ""}: + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +from autorelease._admission import ( # noqa: E402 + PROHIBITED_AGENT_AUTHORITY, + REQUIRED_PLAN_CHECKS, + _validate_support_policy_document, + changed_paths, + git, + seal_patch, + validate_completion_assessment, + validate_plan, + validate_stable_release_evidence, + validate_support_policy, + validate_task_contract, + verify_merge, ) -COMPLETION_EVIDENCE_REF_RE = re.compile( - r"^(evidence\[\d+\]|preconditions\.(?:phpBinHead|misePhpHead|supportPolicyDigest)|" - r"researchSources\[\d+\])$" +from autorelease._evidence import ( # noqa: E402 + EVIDENCE_CAPTURE_IDS, + RUNTIME_PLAN_EVIDENCE_IDS, + EvidenceSource, + RestrictedRedirect, + capture_evidence, + load_capture, + load_plan_evidence, + manifest_digest, + validate_evidence_attestation_predicate, + validate_evidence_state_record, + validate_recaptured_evidence, ) -REQUIRED_PLAN_CHECKS = ["Script checks"] -EVIDENCE_CAPTURE_IDS = { - "php_supported_versions", - "php_release_feed", - "php_source_tags", - "php_bin_releases", - "php_bin_state", - "mise_php_releases", - "mise_php_state", -} -RUNTIME_PLAN_EVIDENCE_IDS = {"evidence_manifest", "watch_decision"} -STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") -# A zero patch component is deliberately excluded: `8.6.0` is equally the tag of a -# `new_branch:8.6` action, so its action key is not derivable from the tag alone. -RECOVERABLE_RELEASE_TAG_RE = re.compile(r"^(\d+\.\d+\.[1-9]\d*)(?:-([1-9]\d*))?$") -PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") -try: - PROTECTED_PATTERNS = tuple(json.loads(PROTECTED_PATHS.read_text())["patterns"]) -except (OSError, KeyError, TypeError, json.JSONDecodeError) as error: - raise RuntimeError(f"cannot load protected paths: {error}") from error -if not all(isinstance(pattern, str) and pattern for pattern in PROTECTED_PATTERNS): - raise RuntimeError("protected paths must be non-empty strings") -SECRET_PATTERNS = ( - re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), - re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), - re.compile(r"\bgh[opusr]_[A-Za-z0-9]{30,}\b"), - re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), +from autorelease._state import ( # noqa: E402 + ACTION_FILENAME_MAP, + LEGAL_EVENT_TRANSITIONS, + LEGAL_RELEASE_TRANSITIONS, + RECOVERABLE_RELEASE_TAG_RE, + WATCH_LIFECYCLE_NOTIFICATION_ACTIONS, + WATCH_PUBLISH_ACTIONS, + WATCH_RECOVERY_ACTION, + action_filename, + audit_reconstruction, + mutation_allowed, + notification_decision, + release_transition, + retained_notification_issue, + retry_decision, + route_watch_action, + transition_event, + unrecorded_published_release, + validate_completed_event_record, + watch_decision, +) +from autorelease._validation import ( # noqa: E402 + ACTION_KEY_RE, + COMMIT_SHA_RE, + COMPLETION_EVIDENCE_REF_RE, + PROTECTED_PATHS, + PROTECTED_PATTERNS, + ROOT, + SECRET_PATTERNS, + SHA256_RE, + STABLE_VERSION_RE, + ControlError, + _archive_member_name, + canonical_json, + contained_path, + instruction_digest, + load_json, + path_is_allowed, + path_is_protected, + require, + resolve_json_pointer, + sha256_bytes, + sha256_file, + utc_now, + validate_archive, + write_json, ) -PROHIBITED_AGENT_AUTHORITY = { - "merge", - "push", - "tag", - "release", - "publish", - "delete_release", - "overwrite_asset", - "workflow_permissions", - "secret_access", -} -LEGAL_EVENT_TRANSITIONS = { - "detected": {"php_bin_ready", "blocked", "needs_human"}, - "php_bin_ready": {"mise_ready", "release_requested", "blocked", "needs_human"}, - "mise_ready": {"release_requested", "complete", "blocked", "needs_human"}, - "release_requested": {"released", "blocked", "needs_human"}, - "released": {"public_install_verified", "blocked", "needs_human"}, - "public_install_verified": {"complete", "blocked", "needs_human"}, - "blocked": {"detected", "php_bin_ready", "mise_ready", "release_requested", "needs_human"}, - "needs_human": {"detected", "php_bin_ready", "mise_ready", "release_requested", "blocked"}, - "complete": set(), -} -LEGAL_RELEASE_TRANSITIONS = { - "requested": "built", - "built": "draft_created", - "draft_created": "draft_verified", - "draft_verified": "published", - "published": "public_verified", - "public_verified": "complete", -} - - -class ControlError(RuntimeError): - """A fail-closed deterministic-control rejection.""" - - -def utc_now() -> str: - return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def canonical_json(value: Any) -> bytes: - return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() - - -def sha256_bytes(value: bytes) -> str: - return "sha256:" + hashlib.sha256(value).hexdigest() - - -def sha256_file(path: pathlib.Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return "sha256:" + digest.hexdigest() - - -def manifest_digest(captures: Iterable[dict[str, Any]]) -> str: - """Digest the identity of an evidence capture set. - - The writer (capture_evidence) and every reader (validate_recaptured_evidence, - the attestation predicate) must agree byte for byte, so the projected fields - and their order live here once. Only captureId, status, and digest are - covered: timestamps and body paths differ between runs that captured - identical evidence. - """ - comparable = [ - {"captureId": item["captureId"], "status": item["status"], "digest": item["digest"]} - for item in captures - ] - return sha256_bytes(canonical_json(comparable)) - - -def load_json(path: pathlib.Path) -> Any: - try: - return json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as error: - raise ControlError(f"cannot load JSON {path}: {error}") from error - - -def write_json(path: pathlib.Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_bytes(canonical_json(value)) - temporary.replace(path) - - -def require(condition: bool, message: str) -> None: - if not condition: - raise ControlError(message) - - -def contained_path(root: pathlib.Path, value: Any, label: str) -> pathlib.Path: - require(isinstance(value, str) and bool(value), f"{label} is missing") - relative = pathlib.PurePosixPath(value) - require(not relative.is_absolute() and ".." not in relative.parts, f"unsafe {label}: {value}") - resolved_root = root.resolve() - resolved = (resolved_root / pathlib.Path(*relative.parts)).resolve() - require(resolved.is_relative_to(resolved_root), f"unsafe {label}: {value}") - return resolved - - -def instruction_digest(path: pathlib.Path) -> str: - require(path.is_file(), f"instruction file does not exist: {path}") - return sha256_file(path) - - -def validate_task_contract(contract: dict[str, Any]) -> None: - require(contract.get("contractVersion") == 1, "unsupported task contract version") - require( - contract.get("phase") in {"investigation", "implementation", "repair"}, - "invalid phase", - ) - for field in ( - "goal", - "actionKey", - "preconditions", - "allowedAuthority", - "nonGoals", - "completionCriteria", - "stopConditions", - ): - require(field in contract, f"task contract is missing {field}") - require(bool(contract["goal"]), "phase goal is empty") - require( - isinstance(contract["allowedAuthority"], list), - "allowedAuthority must be an array", - ) - require( - all(isinstance(item, str) for item in contract["allowedAuthority"]), - "allowedAuthority must contain only strings", - ) - require( - not (set(contract["allowedAuthority"]) & PROHIBITED_AGENT_AUTHORITY), - "agent contract grants prohibited irreversible authority", - ) - criteria = contract["completionCriteria"] - require(isinstance(criteria, list) and criteria, "completion criteria are empty") - require(all(isinstance(item, dict) for item in criteria), "completion criteria must be objects") - ids = [criterion.get("id") for criterion in criteria] - require(all(isinstance(item, str) and item for item in ids), "criterion id is missing") - require(len(ids) == len(set(ids)), "criterion ids are not unique") - for criterion in criteria: - require(bool(criterion.get("requirement")), "criterion requirement is missing") - require( - bool(criterion.get("evidenceRequired")), - "criterion evidence requirement is missing", - ) - - -def validate_completion_assessment( - assessment: dict[str, Any], - contract: dict[str, Any], - expected_digests: dict[str, str] | None = None, -) -> None: - validate_task_contract(contract) - require(assessment.get("contractVersion") == 1, "unsupported assessment version") - if expected_digests is not None: - require( - assessment.get("instructionDigests") == expected_digests, - "assessment instruction digests do not match admitted inputs", - ) - status = assessment.get("phaseStatus") - require(status in {"complete", "blocked", "needs_human"}, "invalid phaseStatus") - require(assessment.get("goNoGo") in {"go", "no_go"}, "invalid goNoGo") - expected_ids = { - criterion["id"] for criterion in contract["completionCriteria"] - } - results = assessment.get("criteria") - require(isinstance(results, list), "assessment criteria must be an array") - result_ids = [result.get("id") for result in results] - require(len(result_ids) == len(set(result_ids)), "duplicate criterion result") - require(set(result_ids) == expected_ids, "criterion results are missing or unexpected") - for result in results: - require( - result.get("status") in {"passed", "failed", "unresolved"}, - f"invalid result for {result.get('id')}", - ) - evidence = result.get("evidence") - require(isinstance(evidence, list), "criterion evidence must be an array") - if result["status"] == "passed": - require(bool(evidence), f"passed criterion {result['id']} has no evidence") - unresolved = assessment.get("unresolved") - require(isinstance(unresolved, list), "unresolved must be an array") - mechanically_go = ( - status == "complete" - and all(result["status"] == "passed" for result in results) - and not unresolved - ) - require( - (assessment["goNoGo"] == "go") == mechanically_go, - "go/no-go is inconsistent with criterion results", - ) - - -def resolve_json_pointer(document: Any, pointer: str) -> Any: - if pointer == "": - return document - require(pointer.startswith("/"), f"invalid JSON pointer: {pointer}") - current = document - for token in pointer[1:].split("/"): - key = token.replace("~1", "/").replace("~0", "~") - if isinstance(current, list): - require(key.isdigit(), f"non-numeric array index in pointer: {pointer}") - index = int(key) - require(index < len(current), f"array index does not resolve: {pointer}") - current = current[index] - else: - require(isinstance(current, dict) and key in current, f"pointer does not resolve: {pointer}") - current = current[key] - return current - - -def validate_stable_release_evidence( - action: str, - release_intent: dict[str, Any] | None, - resolved_evidence: list[dict[str, Any]], -) -> None: - if action not in {"new_patch", "new_branch"}: - return - require(isinstance(release_intent, dict), "stable release action has no release intent") - version = release_intent.get("version") - require( - any( - item.get("captureId") == "php_release_feed" and item.get("value") == version - for item in resolved_evidence - ), - "stable release version is not exact evidence in the official PHP release feed", - ) - - -def validate_recaptured_evidence( - plan: dict[str, Any], - admitted_manifest: dict[str, Any], - current_manifest: dict[str, Any], -) -> dict[str, Any]: - """Verify cited authoritative captures while allowing runtime-only evidence.""" - - def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str, Any]]: - require(isinstance(manifest, dict), f"{label} evidence manifest must be an object") - require(manifest.get("schemaVersion") == 1, f"{label} evidence manifest version is invalid") - captures = manifest.get("captures") - require(isinstance(captures, list), f"{label} evidence captures must be an array") - indexed: dict[str, dict[str, Any]] = {} - for capture in captures: - require(isinstance(capture, dict), f"{label} evidence capture must be an object") - capture_id = capture.get("captureId") - digest = capture.get("digest") - require(capture_id in EVIDENCE_CAPTURE_IDS, f"{label} evidence capture is unknown") - require(capture_id not in indexed, f"{label} evidence capture is duplicated: {capture_id}") - require(capture.get("status") == 200, f"{label} evidence capture is not healthy: {capture_id}") - require(bool(SHA256_RE.fullmatch(digest or "")), f"{label} evidence digest is invalid: {capture_id}") - indexed[capture_id] = capture - require(set(indexed) == EVIDENCE_CAPTURE_IDS, f"{label} evidence capture set changed") - require( - manifest.get("manifestDigest") == manifest_digest(captures), - f"{label} evidence manifest digest mismatch", - ) - return indexed - - admitted = indexed_captures(admitted_manifest, "admitted") - current = indexed_captures(current_manifest, "current") - evidence = plan.get("evidence") - require(isinstance(evidence, list) and bool(evidence), "autorelease plan has no evidence") - verified = [] - for item in evidence: - require(isinstance(item, dict), "plan evidence entry must be an object") - capture_id = item.get("captureId") - digest = item.get("digest") - require(bool(SHA256_RE.fullmatch(digest or "")), f"plan evidence digest is invalid: {capture_id}") - if capture_id in RUNTIME_PLAN_EVIDENCE_IDS: - continue - require(capture_id in admitted, f"plan evidence capture is unknown: {capture_id}") - require(admitted[capture_id]["digest"] == digest, f"admitted evidence digest mismatch: {capture_id}") - require(current[capture_id]["digest"] == digest, f"recaptured evidence changed: {capture_id}") - verified.append(capture_id) - require(bool(verified), "autorelease plan cites no authoritative captured evidence") - return {"valid": True, "verifiedCaptureIds": sorted(verified)} - - -def validate_completed_event_record(record: dict[str, Any]) -> None: - """Validate a durable event as a complete, contiguous legal transition history.""" - - require(isinstance(record, dict), "autorelease event must be an object") - require(record.get("schemaVersion") == 1, "autorelease event version is invalid") - require(bool(ACTION_KEY_RE.fullmatch(record.get("actionKey", ""))), "autorelease event action key is invalid") - require(record.get("state") == "complete", "autorelease event is not complete") - history = record.get("history") - require(isinstance(history, list) and bool(history), "autorelease event has no transition history") - current = history[0].get("from") if isinstance(history[0], dict) else None - for transition in history: - require(isinstance(transition, dict), "autorelease event transition must be an object") - require( - set(transition) == {"from", "to", "at", "evidence"}, - "autorelease event transition fields changed", - ) - require(transition.get("from") == current, "autorelease event history is not contiguous") - target = transition.get("to") - require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), "autorelease event transition is illegal") - timestamp = transition.get("at") - require( - isinstance(timestamp, str) and timestamp.endswith("Z"), - "autorelease event transition timestamp is invalid", - ) - evidence = transition.get("evidence") - require( - isinstance(evidence, list) - and bool(evidence) - and all(isinstance(item, dict) and bool(item) for item in evidence), - "autorelease event transition evidence is invalid", - ) - current = target - require(current == record["state"], "autorelease event state does not match its history") - - -def validate_evidence_state_record(record: dict[str, Any]) -> None: - require(isinstance(record, dict), "evidence state must be an object") - require( - set(record) == {"schemaVersion", "manifestDigest", "planDigest", "captures"}, - "evidence state fields changed", - ) - require(record.get("schemaVersion") == 1, "invalid evidence state version") - require(bool(SHA256_RE.fullmatch(record.get("manifestDigest", ""))), "invalid evidence manifest digest") - require(bool(SHA256_RE.fullmatch(record.get("planDigest", ""))), "invalid evidence plan digest") - captures = record.get("captures") - require(isinstance(captures, list), "evidence captures must be an array") - capture_ids = [] - for capture in captures: - require(isinstance(capture, dict), "evidence capture must be an object") - require(set(capture) == {"captureId", "digest", "status"}, "evidence capture fields changed") - capture_ids.append(capture.get("captureId")) - require(bool(SHA256_RE.fullmatch(capture.get("digest", ""))), "invalid evidence capture digest") - require(capture.get("status") == 200, "evidence capture status is not healthy") - require(len(capture_ids) == len(set(capture_ids)), "duplicate evidence capture") - require(set(capture_ids) == EVIDENCE_CAPTURE_IDS, "evidence capture set changed") - - -def validate_evidence_attestation_predicate( - predicate: dict[str, Any], - *, - run_id: str, - source_sha: str, - action_key: str, - manifest_digest: str, -) -> None: - require(isinstance(predicate, dict), "evidence attestation predicate must be an object") - require( - set(predicate) == {"schemaVersion", "runId", "sourceSha", "actionKey", "manifestDigest"}, - "evidence attestation predicate fields changed", - ) - require(predicate.get("schemaVersion") == 1, "invalid evidence attestation predicate version") - require(bool(re.fullmatch(r"[1-9][0-9]*", run_id)), "invalid expected watcher run") - require(bool(COMMIT_SHA_RE.fullmatch(source_sha)), "invalid expected watcher source") - require(bool(ACTION_KEY_RE.fullmatch(action_key)), "invalid expected watcher action") - require(bool(SHA256_RE.fullmatch(manifest_digest)), "invalid expected evidence manifest") - require(predicate.get("runId") == run_id, "evidence attestation run mismatch") - require(predicate.get("sourceSha") == source_sha, "evidence attestation source mismatch") - require(predicate.get("actionKey") == action_key, "evidence attestation action mismatch") - require( - predicate.get("manifestDigest") == manifest_digest, - "evidence attestation manifest mismatch", - ) - - -def load_capture(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: - manifest = load_json(manifest_path) - require(isinstance(manifest, dict), "capture manifest must be an object") - captures = manifest.get("captures", []) - require(isinstance(captures, list), "capture manifest captures must be an array") - matches = [item for item in captures if isinstance(item, dict) and item.get("captureId") == capture_id] - require(len(matches) == 1, f"capture {capture_id} does not resolve exactly once") - capture = matches[0] - body_path = contained_path(manifest_path.parent, capture.get("bodyPath"), "capture body path") - require(body_path.is_file(), f"capture body is missing: {body_path}") - body = body_path.read_bytes() - require(sha256_bytes(body) == capture.get("digest"), f"capture digest mismatch: {capture_id}") - return capture, body - - -def load_plan_evidence(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: - if capture_id not in RUNTIME_PLAN_EVIDENCE_IDS: - return load_capture(manifest_path, capture_id) - runtime_root = manifest_path.parent.parent - path = { - "evidence_manifest": manifest_path, - "watch_decision": runtime_root / "watch-decision.json", - }[capture_id] - require(path.is_file(), f"runtime plan evidence is unavailable: {capture_id}") - body = path.read_bytes() - return {"captureId": capture_id, "digest": sha256_bytes(body)}, body - - -def path_is_protected(path: str) -> bool: - normalized = pathlib.PurePosixPath(path).as_posix() - return any(fnmatch.fnmatch(normalized, pattern) for pattern in PROTECTED_PATTERNS) - - -def path_is_allowed(path: str, patterns: Iterable[str]) -> bool: - normalized = pathlib.PurePosixPath(path).as_posix() - return any(fnmatch.fnmatch(normalized, pattern) for pattern in patterns) - - -def _validate_support_policy_document( - policy: Any, - invariants_path: pathlib.Path, -) -> tuple[list[str], list[str]]: - require(isinstance(policy, dict), "support policy must be an object") - require( - set(policy) - == { - "schemaVersion", - "policyInvariantsDigest", - "maintainedBranches", - "sourceEvidenceDigests", - "actionKey", - "acceptedAt", - }, - "support policy contains unknown or missing fields", - ) - require(policy.get("schemaVersion") == 1, "unsupported support policy version") - require( - policy.get("policyInvariantsDigest") == sha256_file(invariants_path), - "support policy is not bound to reviewed invariants", - ) - branches = policy.get("maintainedBranches") - require( - isinstance(branches, list) - and all(isinstance(value, str) and re.fullmatch(r"\d+\.\d+", value) for value in branches) - and branches == sorted(set(branches), key=lambda value: tuple(map(int, value.split(".")))), - "support policy branches are invalid or non-canonical", - ) - evidence = policy.get("sourceEvidenceDigests") - require( - isinstance(evidence, list) - and all(isinstance(value, str) and SHA256_RE.fullmatch(value) for value in evidence) - and evidence == sorted(set(evidence)), - "support policy contains invalid or non-canonical evidence digests", - ) - try: - accepted_at = dt.datetime.strptime(policy.get("acceptedAt", ""), "%Y-%m-%dT%H:%M:%SZ") - except (TypeError, ValueError): - accepted_at = None - require(accepted_at is not None, "support policy acceptance time is invalid") - return branches, evidence - - -def validate_support_policy(root: pathlib.Path = ROOT) -> dict[str, Any]: - invariants_path = root / "autorelease/policy-invariants.json" - policy_path = root / "support-policy.json" - invariants = load_json(invariants_path) - policy = load_json(policy_path) - require(isinstance(invariants, dict), "policy invariants must be an object") - require( - set(invariants) - == { - "schemaVersion", - "target", - "allowPrereleases", - "historicalExactVersionsRemainInstallable", - "immutablePublishedAssets", - }, - "policy invariants contain unknown or missing fields", - ) - require(invariants.get("schemaVersion") == 1, "unsupported policy invariants version") - require( - invariants.get("target") - == {"os": "macOS", "minimumVersion": "26.0", "architecture": "arm64", "sapi": "cli"}, - "reviewed target invariant changed", - ) - require(invariants.get("allowPrereleases") is False, "prereleases must remain forbidden") - require( - invariants.get("historicalExactVersionsRemainInstallable") is True, - "historical exact installs must remain enabled", - ) - require(invariants.get("immutablePublishedAssets") is True, "published assets must remain immutable") - _branches, evidence = _validate_support_policy_document(policy, invariants_path) - action_key = policy.get("actionKey") - require( - action_key == "bootstrap" - or bool(re.fullmatch(r"(?:new_branch:\d+\.\d+|branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2})", action_key or "")), - "invalid support policy action key", - ) - require(action_key == "bootstrap" or bool(evidence), "accepted support policy lacks evidence") - return { - "valid": True, - "policyDigest": sha256_file(policy_path), - "invariantsDigest": sha256_file(invariants_path), - } - - -def validate_plan( - plan: dict[str, Any], - manifest_path: pathlib.Path, - contract: dict[str, Any], - shared_path: pathlib.Path, - phase_path: pathlib.Path, - event_contract_path: pathlib.Path, - repo_heads: dict[str, str] | None = None, - policy_digest: str | None = None, - completed_actions: set[str] | None = None, -) -> dict[str, Any]: - require(plan.get("schemaVersion") == 1, "unsupported autorelease plan version") - require( - plan.get("action") - in { - "no_change", - "new_patch", - "new_branch", - "branch_eol", - "repair", - "reconcile_partial", - "blocked", - "needs_human", - }, - "invalid autorelease action", - ) - action_key = plan.get("actionKey", "") - require(bool(ACTION_KEY_RE.fullmatch(action_key)), "invalid action key") - if plan.get("action") == "no_change": - manifest_digest = load_json(manifest_path).get("manifestDigest", "") - require( - action_key == f"no_change:{manifest_digest.removeprefix('sha256:')[:16]}", - "no-change action key is not bound to the evidence manifest", - ) - require(plan.get("editsRequired") is False, "no-change plan cannot require edits") - require(not plan.get("releaseIntent"), "no-change plan cannot request a release") - elif plan.get("action") not in {"blocked", "needs_human"}: - require(plan.get("editsRequired") in {True, False}, "plan must declare whether edits are required") - require( - action_key not in (completed_actions or set()), - "action key already completed", - ) - expected_digests = { - "shared": instruction_digest(shared_path), - "phaseTemplate": instruction_digest(phase_path), - "eventContract": instruction_digest(event_contract_path), - } - agent_contract = plan.get("agentContract", {}) - require(agent_contract.get("contractVersion") == 1, "invalid agent contract version") - require( - agent_contract.get("instructionDigests") == expected_digests, - "plan instruction digests do not match supplied instructions", - ) - validate_completion_assessment( - { - **plan.get("completionAssessment", {}), - "contractVersion": 1, - "instructionDigests": expected_digests, - }, - contract, - expected_digests, - ) - if plan["action"] in {"blocked", "needs_human"}: - require( - plan["completionAssessment"]["goNoGo"] == "no_go", - "blocked plans cannot advance", - ) - else: - require( - plan["completionAssessment"]["goNoGo"] == "go", - "only an internally complete agent plan can advance", - ) - declared_heads = plan.get("preconditions", {}) - require(isinstance(declared_heads, dict), "preconditions must be an object") - if repo_heads: - for key, value in repo_heads.items(): - require(declared_heads.get(key) == value, f"stale repository precondition: {key}") - if policy_digest is not None: - require( - declared_heads.get("supportPolicyDigest") == policy_digest, - "stale support policy precondition", - ) - evidence_refs = {} - resolved_evidence = [] - for index, evidence in enumerate(plan.get("evidence", [])): - capture, body = load_plan_evidence(manifest_path, evidence.get("captureId", "")) - require(evidence.get("digest") == capture["digest"], "plan evidence digest mismatch") - locator = evidence.get("locator", {}) - if locator.get("kind") == "json_pointer": - try: - document = json.loads(body) - except json.JSONDecodeError as error: - raise ControlError("JSON locator targets a non-JSON capture") from error - resolved_value = resolve_json_pointer(document, locator.get("value", "")) - elif locator.get("kind") == "text_fragment": - fragment = locator.get("value", "") - require(bool(fragment) and fragment.encode() in body, "text locator does not resolve") - resolved_value = fragment - else: - raise ControlError("unsupported evidence locator") - evidence_refs[f"evidence[{index}]"] = evidence - resolved_evidence.append( - {"captureId": evidence.get("captureId"), "value": resolved_value} - ) - research_sources = plan.get("researchSources", []) - require(isinstance(research_sources, list), "researchSources must be an array") - precondition_refs = {f"preconditions.{key}" for key in declared_heads} - source_refs = {f"researchSources[{index}]" for index in range(len(research_sources))} - for result in plan["completionAssessment"]["criteria"]: - for reference in result["evidence"]: - require( - bool(COMPLETION_EVIDENCE_REF_RE.fullmatch(reference)), - f"invalid criterion evidence reference: {reference}", - ) - require( - reference in evidence_refs - or reference in precondition_refs - or reference in source_refs, - f"criterion evidence reference does not resolve: {reference}", - ) - allowed_paths = plan.get("allowedPaths", {}) - require(isinstance(allowed_paths, dict), "allowedPaths must be an object") - for patterns in allowed_paths.values(): - require(isinstance(patterns, list), "allowed path set must be an array") - for pattern in patterns: - pure = pathlib.PurePosixPath(pattern) - require(not pure.is_absolute() and ".." not in pure.parts, f"unsafe allowed path: {pattern}") - require( - not path_is_protected(pattern), - f"protected path cannot be admitted for runtime editing: {pattern}", - ) - if fnmatch.fnmatch("support-policy.json", pattern): - require(plan.get("risk") == "lifecycle", "support state requires lifecycle risk") - require(plan.get("action") in {"new_branch", "branch_eol"}, "support state requires a lifecycle action") - repositories = plan.get("repositories") - require( - isinstance(repositories, list) - and "php-bin" in repositories - and all(value in {"php-bin", "mise-php"} for value in repositories), - "plan repository authority is invalid", - ) - require(plan.get("requiredChecks") == REQUIRED_PLAN_CHECKS, "required deterministic checks changed") - release_intent = plan.get("releaseIntent") - if release_intent is not None: - require(isinstance(release_intent, dict), "releaseIntent must be an object or null") - version = release_intent.get("version", "") - require(bool(STABLE_VERSION_RE.fullmatch(version)), "release version is not stable") - require( - not re.search(r"(?:alpha|beta|rc|dev)", version, re.I), - "prerelease intent is forbidden", - ) - validate_stable_release_evidence(plan.get("action", ""), release_intent, resolved_evidence) - operations = plan.get("agentOperations") - require(isinstance(operations, list), "agentOperations must be an array") - require(all(isinstance(operation, str) for operation in operations), "agentOperations must contain strings") - for operation in operations: - require(operation not in PROHIBITED_AGENT_AUTHORITY, f"prohibited agent operation: {operation}") - budgets = plan.get("budgets") - require(isinstance(budgets, dict) and bool(budgets), "plan must declare reviewed budgets") - for field, upper, label in ( - ("maxModelCalls", 5, "model-call"), - ("maxRetries", 3, "retry"), - ("timeoutMinutes", 60, "time"), - ): - value = budgets.get(field) - require(isinstance(value, int) and not isinstance(value, bool), f"{field} must be an integer") - require(0 < value <= upper, f"{label} budget is outside reviewed bound") - return { - "admitted": True, - "admittedAt": utc_now(), - "actionKey": action_key, - "planDigest": sha256_bytes(canonical_json(plan)), - "instructionDigests": expected_digests, - } - - -def git(repo: pathlib.Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", *arguments], - cwd=repo, - check=check, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - -def changed_paths(repo: pathlib.Path, base: str) -> list[str]: - result = git(repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, "--") - paths = [line for line in result.stdout.splitlines() if line] - untracked = git(repo, "ls-files", "--others", "--exclude-standard").stdout.splitlines() - return sorted(set(paths + untracked)) - - -def seal_patch( - repo: pathlib.Path, - base: str, - plan: dict[str, Any], - result: dict[str, Any], - contract: dict[str, Any], - output_dir: pathlib.Path, -) -> dict[str, Any]: - expected_digests = plan["agentContract"]["instructionDigests"] - validate_completion_assessment(result, contract, expected_digests) - require(result["goNoGo"] == "go", "implementation result is no-go") - require(bool(COMMIT_SHA_RE.fullmatch(base or "")), "base is not an exact commit SHA") - require(git(repo, "rev-parse", f"{base}^{{commit}}").stdout.strip() == base, "base is not an exact commit") - paths = changed_paths(repo, base) - require(bool(paths), "implementation produced no patch") - admitted = [ - item - for patterns in plan.get("allowedPaths", {}).values() - for item in patterns - ] - for path in paths: - require(not path_is_protected(path), f"patch changes protected path: {path}") - require(path_is_allowed(path, admitted), f"patch changes unadmitted path: {path}") - candidate = repo / path - if candidate.exists(): - require(not candidate.is_symlink(), f"patch contains symlink: {path}") - require(candidate.is_file(), f"patch contains unsupported entry: {path}") - require(candidate.stat().st_size <= 2 * 1024 * 1024, f"patch file too large: {path}") - mode = candidate.stat().st_mode & 0o777 - require(mode in {0o644, 0o755}, f"patch contains unexpected mode: {path}") - require(mode != 0o755 or path.startswith("scripts/"), f"unexpected executable path: {path}") - body = candidate.read_bytes() - require(b"\0" not in body, f"patch contains binary file: {path}") - try: - decoded = body.decode("utf-8") - except UnicodeDecodeError as error: - raise ControlError(f"patch file is not valid UTF-8: {path}") from error - for pattern in SECRET_PATTERNS: - require(not pattern.search(decoded), f"patch contains secret-like material: {path}") - if path == "support-policy.json": - try: - policy = json.loads(decoded) - except json.JSONDecodeError as error: - raise ControlError("support policy is not valid JSON") from error - _branches, policy_evidence = _validate_support_policy_document( - policy, - repo / "autorelease/policy-invariants.json", - ) - evidence_digests = sorted( - {item.get("digest") for item in plan.get("evidence", []) if item.get("digest")} - ) - require( - policy_evidence == evidence_digests and bool(evidence_digests), - "support policy is not bound to admitted captured evidence", - ) - require(policy.get("actionKey") == plan.get("actionKey"), "support policy action key changed") - output_dir.mkdir(parents=True, exist_ok=True) - patch_path = output_dir / "sealed.patch" - tracked_patch = git(repo, "diff", "--binary", "--full-index", base, "--").stdout - untracked_patch_parts = [] - for path in git(repo, "ls-files", "--others", "--exclude-standard").stdout.splitlines(): - proc = subprocess.run( - ["git", "diff", "--binary", "--no-index", "--", "/dev/null", path], - cwd=repo, - check=False, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - require(proc.returncode in {0, 1}, f"failed to serialize untracked path: {path}") - untracked_patch_parts.append(proc.stdout) - patch_path.write_text(tracked_patch + "".join(untracked_patch_parts)) - require(patch_path.stat().st_size <= 4 * 1024 * 1024, "sealed patch exceeds size limit") - files = [] - for path in paths: - candidate = repo / path - files.append( - { - "path": path, - "digest": sha256_file(candidate) if candidate.is_file() else None, - "mode": oct(candidate.stat().st_mode & 0o777) if candidate.exists() else None, - } - ) - manifest = { - "schemaVersion": 1, - "baseSha": base, - "actionKey": plan["actionKey"], - "planDigest": sha256_bytes(canonical_json(plan)), - "patchDigest": sha256_file(patch_path), - "files": files, - "sealedAt": utc_now(), - } - write_json(output_dir / "patch-manifest.json", manifest) - return manifest - - -def verify_merge( - repo: pathlib.Path, - expected_head: str, - manifest: dict[str, Any], - checks: dict[str, Any], - preconditions: dict[str, str], - current: dict[str, str], - readiness: list[dict[str, Any]] | None = None, -) -> dict[str, Any]: - require(bool(COMMIT_SHA_RE.fullmatch(expected_head or "")), "expected head is not an exact commit SHA") - actual_head = git(repo, "rev-parse", "HEAD").stdout.strip() - require(actual_head == expected_head, "PR head does not equal validated SHA") - require(checks and all(value == "success" for value in checks.values()), "required checks did not succeed") - require(preconditions == current, "merge preconditions changed") - base_sha = manifest.get("baseSha") - require(bool(COMMIT_SHA_RE.fullmatch(base_sha or "")), "sealed manifest has no exact base SHA") - require( - git(repo, "rev-list", "--parents", "-n", "1", expected_head).stdout.split() - == [expected_head, base_sha], - "validated commit is not a single commit on the sealed base", - ) - actual_paths = set( - git( - repo, - "diff", - "--name-only", - "--diff-filter=ACDMRTUXB", - base_sha, - expected_head, - "--", - ).stdout.splitlines() - ) - file_records = manifest.get("files", []) - require(isinstance(file_records, list), "sealed manifest files are invalid") - manifest_paths = {item.get("path") for item in file_records if isinstance(item, dict)} - require(len(manifest_paths) == len(file_records) and None not in manifest_paths, "sealed manifest paths are invalid") - require(actual_paths == manifest_paths, "final diff does not equal the sealed manifest") - for file_record in file_records: - path = file_record["path"] - require(not path_is_protected(path), f"sealed manifest contains protected path: {path}") - candidate = repo / path - expected = file_record.get("digest") - require(candidate.is_file() if expected else not candidate.exists(), f"manifest path mismatch: {path}") - if expected: - require(sha256_file(candidate) == expected, f"validated file changed: {path}") - require( - oct(candidate.stat().st_mode & 0o777) == file_record.get("mode"), - f"validated file mode changed: {path}", - ) - for record in readiness or []: - require(record.get("ready") is True, "cross-repository readiness is missing") - require(bool(record.get("commit")), "readiness record has no exact commit") - return {"admitted": True, "headSha": actual_head, "verifiedAt": utc_now()} - - -def transition_event(event: dict[str, Any], target: str, evidence: list[dict[str, Any]]) -> dict[str, Any]: - current = event.get("state", "detected") - require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), f"illegal event transition: {current} -> {target}") - require(bool(evidence), "event transition requires evidence") - updated = json.loads(json.dumps(event)) - updated["state"] = target - updated.setdefault("history", []).append( - {"from": current, "to": target, "at": utc_now(), "evidence": evidence} - ) - return updated - - -def release_transition( - transaction: dict[str, Any], - target: str, - assets_dir: pathlib.Path, - expected_assets: dict[str, str], -) -> dict[str, Any]: - current = transaction.get("state", "requested") - require(LEGAL_RELEASE_TRANSITIONS.get(current) == target, f"illegal release transition: {current} -> {target}") - published = transaction.get("publishedAssets", {}) - if published: - require(published == expected_assets, "published asset inconsistency") - if target in {"draft_verified", "published", "public_verified", "complete"}: - for name, digest in expected_assets.items(): - path = assets_dir / name - require(path.is_file(), f"release asset is missing: {name}") - require(sha256_file(path) == digest, f"release asset digest mismatch: {name}") - updated = json.loads(json.dumps(transaction)) - updated["state"] = target - updated["assetDigests"] = expected_assets - if target == "published": - updated["publishedAssets"] = expected_assets - updated.setdefault("history", []).append({"from": current, "to": target, "at": utc_now()}) - return updated - - -def notification_decision(event: dict[str, Any], prior: dict[str, Any] | None) -> dict[str, Any]: - fingerprint_fields = { - "state": event.get("state"), - "evidenceDigest": event.get("evidenceDigest"), - "failureFingerprint": event.get("failureFingerprint"), - "humanActionRequired": bool(event.get("humanActionRequired")), - "finalResult": event.get("finalResult"), - } - fingerprint = sha256_bytes(canonical_json(fingerprint_fields)) - if prior and prior.get("fingerprint") == fingerprint: - return {"action": "none", "fingerprint": fingerprint} - if prior is None: - action = "create_and_close" if event.get("state") == "complete" else "create" - elif event.get("state") == "complete": - action = "comment_and_close" - else: - action = "comment" - severity = event.get("severity", "info") - critical = severity == "critical" - return { - "action": action, - "fingerprint": fingerprint, - "critical": critical, - "labels": ["autorelease", *(["attention-required"] if critical or event.get("humanActionRequired") else [])], - } - - -def retained_notification_issue(prior: dict[str, Any] | None) -> dict[str, Any] | None: - """Return a usable retained issue identity without relying on search indexing.""" - issue = (prior or {}).get("issue") - number = issue.get("number") if isinstance(issue, dict) else None - if not isinstance(number, bool) and isinstance(number, int) and number > 0: - return issue - return None - - -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. - - Every event record, readiness record, and automation branch in both repositories is - named from its action key by this one mapping, so the name is only ever derived here. - The key is model-authored and reaches shell arguments and repository paths, so its - alphabet is re-asserted at this boundary rather than trusted from the caller. - """ - require(bool(ACTION_KEY_RE.fullmatch(action_key)), f"invalid action key: {action_key}") - return action_key.translate(ACTION_FILENAME_MAP) + suffix - - -def unrecorded_published_release( - releases: Iterable[dict[str, Any]], - events: Iterable[dict[str, Any]], - record_files: Iterable[str] = (), -) -> str | None: - """Return the action key of one published release that has no event record at all. - - A live release with no record silently corrupts every later decision, because the - completed-action ledger is what admission uses to tell finished work from new work. - Recovery is fail-closed: a release is only claimed when immutability proves it came - from the guarded publish transaction and its action key is derivable from the tag - alone. Any existing record, complete or not, is left to its own path, and so is a - key whose record filename is already occupied by an unrelated document, because the - filer refuses to overwrite a file and would otherwise fail on every later run. One - key is returned per run; a further backlog is repaired by later runs. - """ - recorded = {event.get("actionKey") for event in events} - occupied = set(record_files) - keys = set() - for release in releases: - if not isinstance(release, dict): - continue - if release.get("draft") or release.get("prerelease") or release.get("immutable") is not True: - continue - tag = RECOVERABLE_RELEASE_TAG_RE.fullmatch(str(release.get("tag_name", ""))) - if tag is None: - continue - key = f"recipe_rebuild:{tag.group(1)}:{tag.group(2)}" if tag.group(2) else f"new_patch:{tag.group(1)}" - if key not in recorded and action_filename(key) not in occupied: - keys.add(key) - return min(keys, default=None) - - -def watch_decision( - manifest: dict[str, Any], - previous: dict[str, Any], - events: Iterable[dict[str, Any]], - health: dict[str, Any], - *, - self_evidence_update: bool = False, - releases: Iterable[dict[str, Any]] = (), - record_files: Iterable[str] = (), -) -> dict[str, Any]: - events = list(events) - incomplete = sorted( - event.get("actionKey") - for event in events - if event.get("state") != "complete" - ) - unrecorded = unrecorded_published_release(releases, events, record_files) - if not health.get("healthy", False): - trigger = "health_failed" - elif any(capture.get("status") != 200 for capture in manifest.get("captures", [])): - trigger = "source_unhealthy" - elif incomplete: - trigger = "event_incomplete" - elif previous.get("manifestDigest") != manifest.get("manifestDigest"): - current_captures = { - item.get("captureId"): (item.get("status"), item.get("digest")) - for item in manifest.get("captures", []) - if isinstance(item, dict) - } - previous_captures = { - item.get("captureId"): (item.get("status"), item.get("digest")) - for item in previous.get("captures", []) - if isinstance(item, dict) - } - changed_captures = { - capture_id - for capture_id in set(current_captures) | set(previous_captures) - if current_captures.get(capture_id) != previous_captures.get(capture_id) - } - trigger = ( - "quiet" - if self_evidence_update and changed_captures == {"php_bin_state"} - else "evidence_changed" - ) - else: - trigger = "quiet" - # A missing record outranks every trigger that a trustworthy snapshot can raise, so - # it is repaired before new work starts. It never changes whether the model is - # called: the repair is deterministic, but suppressing the investigation would let a - # blocked repair starve reconciliation and selection on every later run. - model_call = trigger != "quiet" - if unrecorded and trigger not in {"health_failed", "source_unhealthy"}: - trigger = "record_missing" - return { - "schemaVersion": 1, - "trigger": trigger, - "manifestDigest": manifest.get("manifestDigest"), - "incompleteActions": incomplete, - "action": "record_completed_event" if trigger == "record_missing" else "none", - "actionKey": unrecorded if trigger == "record_missing" else "", - "modelCall": model_call, - } - - -# Only these two admitted actions announce themselves before their route runs, and only -# these three select a release for the publish transaction. -WATCH_LIFECYCLE_NOTIFICATION_ACTIONS = frozenset({"new_branch", "branch_eol"}) -# `watch_decision` names a missing event record as its own action. The recovery overlay -# owns that repair, so it is a route the plan never takes rather than an unrouted one. -WATCH_RECOVERY_ACTION = "record_completed_event" -WATCH_PUBLISH_ACTIONS = frozenset({"new_patch", "new_branch", "reconcile_partial"}) - - -def route_watch_action(decision: dict[str, Any]) -> dict[str, Any]: - """Return the one route a coordinated watcher decision takes, or raise. - - The watcher runs two independent routes in the same job: `route` dispatches the - admitted plan, and `recoveryRoute` repairs a published release that has no event - record. Recovery is an overlay rather than an exclusive branch, so it carries its own - field and never competes with the plan for one. - - Every legal combination is enumerated, including the ones that legitimately do - nothing — those return `route: "none"` with the reason, so an idle run stays green. - Anything else raises instead of falling through to a silent success, which is what an - unrouted combination used to do. - """ - action = str(decision.get("action") or "") - action_key = str(decision.get("actionKey") or "") - record_action_key = str(decision.get("recordActionKey") or "") - edits_required = bool(decision.get("editsRequired")) - recovery_merged = bool(decision.get("recoveryMerged")) - evidence_recorded = bool(decision.get("evidenceAlreadyRecorded")) - - # The workflow passes the recovery key separately, but a caller handing this function - # a raw `watch_decision` carries it as that decision's own key, so both are accepted. - recovery_key = record_action_key or (action_key if action == WATCH_RECOVERY_ACTION else "") - - def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: - return { - "schemaVersion": 1, - "route": route, - "reason": reason, - "notify": notify, - "action": action, - "actionKey": action_key, - "recordActionKey": recovery_key, - "recoveryRoute": "recover_record" if recovery_key else "none", - } - - if action in {"", "none"}: - return routed("none", "no_admitted_plan") - if action == WATCH_RECOVERY_ACTION: - return routed("none", "recovery_routed_by_recovery_route") - if recovery_merged and action == "branch_eol": - # The completion asserts an untouched base, which the recovered record just moved. - return routed("none", "eol_completion_deferred_by_recovery") - if action == "no_change" and evidence_recorded: - return routed("none", "evidence_state_already_recorded") - if action in {"blocked", "needs_human"}: - return routed("notify_blocked", "operator_attention_required") - notify = "lifecycle" if action in WATCH_LIFECYCLE_NOTIFICATION_ACTIONS else "none" - if action == "no_change": - return routed("no_change_evidence", "record_reviewed_evidence", notify) - if edits_required: - return routed("dispatch_implementation", "admitted_plan_requires_edits", notify) - if action in WATCH_PUBLISH_ACTIONS: - if record_action_key and action_key == record_action_key: - # The ledger this plan was admitted against is the one missing this record, - # so the release it selects is already public. - return routed("none", "release_published_pending_record", notify) - return routed("dispatch_publish", "publish_admitted_release", notify) - if action == "branch_eol": - return routed("complete_branch_eol", "complete_admitted_eol", notify) - raise ControlError(f"watcher action is unrouted: {action} with editsRequired={edits_required}") - - -def retry_decision( - event: dict[str, Any], - failure_fingerprint: str, - max_attempts: int, -) -> dict[str, Any]: - """Decide whether a failed agent phase may be recalled. - - No workflow calls this: the retry budget is an acceptance property, asserted - by autorelease/verify.py check A06, which proves an identical repeated - failure can never spend an unbounded number of agent runs. - """ - require(0 < max_attempts <= 5, "retry budget is outside the reviewed bound") - attempts = int(event.get("attemptCount", 0)) - previous = event.get("failureFingerprint") - if previous == failure_fingerprint and attempts >= max_attempts: - return {"recallAgent": False, "reason": "identical_failure_exhausted", "attemptCount": attempts} - if previous == failure_fingerprint and event.get("lastRejectionRepeated", False): - return {"recallAgent": False, "reason": "identical_rejection", "attemptCount": attempts} - return {"recallAgent": attempts < max_attempts, "reason": "bounded_retry", "attemptCount": attempts + 1} - - -def mutation_allowed(operator_state: dict[str, Any]) -> bool: - return operator_state.get("unattendedMutation") == "enabled" - - -def audit_reconstruction(event: dict[str, Any], root: pathlib.Path) -> dict[str, Any]: - """Replay a completed event from its retained evidence alone. - - No workflow calls this: auditability is an acceptance property, asserted by - autorelease/verify.py check A19, which proves a finished action can be - reconstructed from the record and rejects it once any cited file is missing - or altered. - """ - required = event.get("auditEvidence", []) - require(isinstance(required, list) and bool(required), "event has no audit evidence") - verified = [] - for item in required: - require(isinstance(item, dict), "audit evidence entry must be an object") - item_path = item.get("path") - item_digest = item.get("digest") - require(isinstance(item_digest, str) and SHA256_RE.fullmatch(item_digest), "audit evidence digest is missing") - path = contained_path(root, item_path, "audit evidence path") - require(path.is_file(), f"audit evidence is unavailable: {item_path}") - require(sha256_file(path) == item_digest, f"audit evidence digest mismatch: {item_path}") - verified.append(item_path) - require(bool(event.get("actionKey")), "audit event has no action key") - require(bool(event.get("history")), "audit event has no transition history") - return {"reconstructed": True, "actionKey": event["actionKey"], "evidence": verified} - - -class RestrictedRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any: - old = urllib.parse.urlparse(req.full_url) - new = urllib.parse.urlparse(newurl) - if new.scheme != "https" or new.hostname != old.hostname: - raise urllib.error.HTTPError(newurl, code, "cross-host redirect rejected", headers, fp) - return super().redirect_request(req, fp, code, msg, headers, newurl) - - -@dataclass(frozen=True) -class EvidenceSource: - capture_id: str - url: str - max_bytes: int +# Which sources are authoritative is a reviewed decision rather than a client detail, so +# the registry stays in this surface and is handed to the capture client. autorelease/ +# verify.py check A11 reads this file to prove the raw sources are still fetched as +# opaque bytes and never parsed into lifecycle state. EVIDENCE_SOURCES = ( EvidenceSource("php_supported_versions", "https://www.php.net/supported-versions.php", 2_000_000), EvidenceSource("php_release_feed", "https://www.php.net/releases/index.php?json", 5_000_000), @@ -1239,116 +126,6 @@ class EvidenceSource: ) -def capture_evidence( - output_dir: pathlib.Path, - sources: Iterable[EvidenceSource] = EVIDENCE_SOURCES, - token: str | None = None, -) -> dict[str, Any]: - output_dir.mkdir(parents=True, exist_ok=True) - opener = urllib.request.build_opener(RestrictedRedirect) - captures = [] - for source in sources: - headers = { - "Accept": "application/vnd.github+json, application/json, text/html", - "User-Agent": "bigpixelrocket-autorelease/1", - } - if token and urllib.parse.urlparse(source.url).hostname == "api.github.com": - headers["Authorization"] = f"Bearer {token}" - request = urllib.request.Request( - source.url, - headers=headers, - ) - last_error: Exception | None = None - for attempt in range(3): - if attempt: - time.sleep(2**attempt) - try: - with opener.open(request, timeout=30) as response: - body = response.read(source.max_bytes + 1) - require(len(body) <= source.max_bytes, f"capture too large: {source.capture_id}") - body_path = pathlib.Path("raw") / f"{source.capture_id}.body" - destination = output_dir / body_path - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(body) - captures.append( - { - "captureId": source.capture_id, - "url": source.url, - "retrievedAt": utc_now(), - "status": response.status, - "contentType": response.headers.get("Content-Type"), - "etag": response.headers.get("ETag"), - "lastModified": response.headers.get("Last-Modified"), - "digest": sha256_bytes(body), - "bodyPath": body_path.as_posix(), - } - ) - last_error = None - break - except ControlError as error: - last_error = error - break - except urllib.error.HTTPError as error: - last_error = error - if error.code not in {408, 429} and not 500 <= error.code < 600: - break - except (OSError, urllib.error.URLError) as error: - last_error = error - if last_error is not None: - body_path = pathlib.Path("raw") / f"{source.capture_id}.body" - destination = output_dir / body_path - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_bytes(b"") - captures.append( - { - "captureId": source.capture_id, - "url": source.url, - "retrievedAt": utc_now(), - "status": 0, - "contentType": None, - "etag": None, - "lastModified": None, - "digest": sha256_bytes(b""), - "bodyPath": body_path.as_posix(), - "error": type(last_error).__name__, - } - ) - manifest = { - "schemaVersion": 1, - "capturedAt": utc_now(), - "captures": captures, - "manifestDigest": "", - } - manifest["manifestDigest"] = manifest_digest(captures) - write_json(output_dir / "evidence-manifest.json", manifest) - return manifest - - -def _archive_member_name(name: str) -> str: - return name[2:] if name.startswith("./") else name - - -def validate_archive(archive: pathlib.Path, version: str) -> None: - require(archive.name == f"php-{version}-cli-macos-aarch64.tar.gz", "unexpected archive name") - try: - with tarfile.open(archive, "r:gz") as handle: - members = handle.getmembers() - except tarfile.TarError as error: - raise ControlError(f"cannot read archive {archive}: {error}") from error - names = set() - for member in members: - normalized = pathlib.PurePosixPath(_archive_member_name(member.name)) - require( - ".." not in normalized.parts - and not normalized.is_absolute() - and not member.name.startswith("/"), - f"unsafe archive path: {member.name}", - ) - require(not member.issym() and not member.islnk(), "archive contains a link") - names.add(normalized.as_posix()) - require("bin/php" in names, "archive does not contain bin/php") - - def cli_flag(value: str, name: str) -> bool: """Read a workflow-supplied boolean, where a skipped step legitimately supplies none.""" require(value in {"", "true", "false"}, f"{name} must be true, false, or empty") @@ -1415,7 +192,13 @@ def main(argv: list[str] | None = None) -> int: validate_completion_assessment(assessment, contract, assessment.get("instructionDigests")) print(json.dumps({"valid": True})) elif args.command == "capture-evidence": - print(json.dumps(capture_evidence(args.output, token=os.environ.get("GITHUB_TOKEN")))) + print( + json.dumps( + capture_evidence( + args.output, EVIDENCE_SOURCES, token=os.environ.get("GITHUB_TOKEN") + ) + ) + ) elif args.command == "validate-recaptured-evidence": print( json.dumps( From 192074a5c49fa156172d61a83ba8600da3fe5d5c Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:41:06 +0300 Subject: [PATCH 34/48] refactor: split plan admission into its three gates validate_plan ran ~175 lines of unrelated rejections in one body. The rejections move unchanged into _validate_plan_shape, _validate_plan_preconditions, and _validate_plan_actions, and validate_plan now names the order they run in, which matters because each gate reads what the previous one proved. --- autorelease/_admission.py | 77 ++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/autorelease/_admission.py b/autorelease/_admission.py index d678a87..8745d7e 100644 --- a/autorelease/_admission.py +++ b/autorelease/_admission.py @@ -247,17 +247,16 @@ def validate_support_policy(root: pathlib.Path = ROOT) -> dict[str, Any]: } -def validate_plan( +def _validate_plan_shape( plan: dict[str, Any], manifest_path: pathlib.Path, - contract: dict[str, Any], - shared_path: pathlib.Path, - phase_path: pathlib.Path, - event_contract_path: pathlib.Path, - repo_heads: dict[str, str] | None = None, - policy_digest: str | None = None, - completed_actions: set[str] | None = None, -) -> dict[str, Any]: + completed_actions: set[str] | None, +) -> str: + """Reject a plan whose identity is wrong, and return the action key it claims. + + Nothing later in admission means anything until the plan names one reviewed + action and one well-formed key that no completed event already owns. + """ require(plan.get("schemaVersion") == 1, "unsupported autorelease plan version") require( plan.get("action") @@ -289,6 +288,23 @@ def validate_plan( action_key not in (completed_actions or set()), "action key already completed", ) + return action_key + + +def _validate_plan_preconditions( + plan: dict[str, Any], + contract: dict[str, Any], + shared_path: pathlib.Path, + phase_path: pathlib.Path, + event_contract_path: pathlib.Path, + repo_heads: dict[str, str] | None, + policy_digest: str | None, +) -> tuple[dict[str, str], dict[str, Any]]: + """Bind the plan to the instructions it was written against and the state it saw. + + Returns the instruction digests the admission record carries and the declared + preconditions, which the plan's own evidence references are resolved against. + """ expected_digests = { "shared": instruction_digest(shared_path), "phaseTemplate": instruction_digest(phase_path), @@ -329,6 +345,19 @@ def validate_plan( declared_heads.get("supportPolicyDigest") == policy_digest, "stale support policy precondition", ) + return expected_digests, declared_heads + + +def _validate_plan_actions( + plan: dict[str, Any], + manifest_path: pathlib.Path, + declared_heads: dict[str, Any], +) -> None: + """Reject the effects the plan asks for: evidence, paths, release, and budgets. + + Every claim is re-derived from the captured bodies and the reviewed bounds + rather than trusted from the plan that asserts it. + """ evidence_refs = {} resolved_evidence = [] for index, evidence in enumerate(plan.get("evidence", [])): @@ -414,6 +443,36 @@ def validate_plan( value = budgets.get(field) require(isinstance(value, int) and not isinstance(value, bool), f"{field} must be an integer") require(0 < value <= upper, f"{label} budget is outside reviewed bound") + + +def validate_plan( + plan: dict[str, Any], + manifest_path: pathlib.Path, + contract: dict[str, Any], + shared_path: pathlib.Path, + phase_path: pathlib.Path, + event_contract_path: pathlib.Path, + repo_heads: dict[str, str] | None = None, + policy_digest: str | None = None, + completed_actions: set[str] | None = None, +) -> dict[str, Any]: + """Admit one agent plan, or reject it. + + The three gates run in a fixed order: what the plan is, what it was written + against, and what it asks for. A later gate reads values the earlier one + proved, so none of them is safe to reorder. + """ + action_key = _validate_plan_shape(plan, manifest_path, completed_actions) + expected_digests, declared_heads = _validate_plan_preconditions( + plan, + contract, + shared_path, + phase_path, + event_contract_path, + repo_heads, + policy_digest, + ) + _validate_plan_actions(plan, manifest_path, declared_heads) return { "admitted": True, "admittedAt": utc_now(), From 6f7b22189e0cf10e02c05b914a4761df5bfa307a Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 18:41:37 +0300 Subject: [PATCH 35/48] docs: state the watcher routing invariant the workflow depends on --- autorelease/_state.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/autorelease/_state.py b/autorelease/_state.py index fad234c..f0068c0 100644 --- a/autorelease/_state.py +++ b/autorelease/_state.py @@ -290,6 +290,14 @@ def route_watch_action(decision: dict[str, Any]) -> dict[str, Any]: nothing — those return `route: "none"` with the reason, so an idle run stays green. Anything else raises instead of falling through to a silent success, which is what an unrouted combination used to do. + + Invariant: `recoveryRoute` must depend on `recordActionKey` alone. The watch workflow + calls this function twice in one run — the recover step reads `recoveryRoute` from a + call that supplies only the record key, then the dispatch step reads `route` from a + call that supplies the whole decision. Both agree today only because the recovery + overlay ignores every other field. A field added to the recovery decision would make + the first call answer from an incomplete decision and silently disagree with the + second, so it must be passed to both callers in the same change. """ action = str(decision.get("action") or "") action_key = str(decision.get("actionKey") or "") From 06cb37d822b57ec9e5c5d7862eca884816635ead Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:05:20 +0300 Subject: [PATCH 36/48] test: scan the whole control package for classifier markers A04 and A11 asserted the absence of BeautifulSoup, support_table_to_events, and classify_php_release by grepping autorelease/control.py alone. Since the control module was split into a package, that file is a 256-line facade, so a parser added to _evidence.py satisfied both checks. Both now read every module in the package through one helper, excluding verify.py itself because it names the markers to assert them absent. A11 keeps reading control.py for the positive half: the authoritative source registry still has to live on the facade. Also tighten the recovery --repo matcher: gh pr required exactly one space, and the continuation fold collapsed across blank lines. --- autorelease/verify.py | 39 ++++++++++++++++++++++++++++++++++----- tests/test_autorelease.py | 4 ++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/autorelease/verify.py b/autorelease/verify.py index 89a32eb..5978a69 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -46,6 +46,28 @@ CANONICAL_CODEX_CONFIG = re.compile( r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/config\.toml"' ) +# Markers of the lifecycle classifier the deterministic controls must never grow. A02 +# proves the controls stay deterministic by behavior; A04 and A11 back that with the +# absence of any parser, so they must read the whole control package rather than the +# facade alone — otherwise moving a parser into a submodule would satisfy both. +FORBIDDEN_CLASSIFIER_MARKERS = ("BeautifulSoup", "support_table_to_events", "classify_php_release") + + +def control_package_source() -> str: + """Return every deterministic control module's text as one searchable string. + + `verify.py` is excluded because it is the harness, not a control: it names the + forbidden markers to assert their absence and would otherwise fail on itself. + """ + modules = sorted( + path for path in (PHP_ROOT / "autorelease").glob("*.py") if path.name != "verify.py" + ) + assert_true( + {"control.py", "_admission.py", "_evidence.py", "_state.py", "_validation.py"} + <= {path.name for path in modules}, + "the control package no longer exposes the modules the absence checks scan", + ) + return "\n".join(path.read_text() for path in modules) def run(*args: str, cwd: pathlib.Path, check: bool = True) -> subprocess.CompletedProcess[str]: @@ -391,9 +413,11 @@ def a04(self, directory: pathlib.Path) -> list[str]: inputs = fixture_admission_inputs(target, action) admit_fixture(inputs) actions[action] = inputs["plan"]["actionKey"] - source = (PHP_ROOT / "autorelease/control.py").read_text() - forbidden_classifier_markers = ("BeautifulSoup", "support_table_to_events", "classify_php_release") - assert_true(not any(item in source for item in forbidden_classifier_markers), "deterministic control contains lifecycle classifier") + source = control_package_source() + assert_true( + not any(item in source for item in FORBIDDEN_CLASSIFIER_MARKERS), + "deterministic control contains lifecycle classifier", + ) (directory / "classifications.json").write_bytes(canonical_json(actions)) return ["classifications.json"] @@ -630,8 +654,13 @@ def a11(self, directory: pathlib.Path) -> list[str]: inputs["manifestPath"].write_bytes(canonical_json(inputs["manifest"])) inputs["plan"]["evidence"][0]["digest"] = sha256_bytes(body) admit_fixture(inputs) - source = (PHP_ROOT / "autorelease/control.py").read_text() - assert_true("supported-versions.php" in source and "BeautifulSoup" not in source, "source-format handling became a lifecycle parser") + registry = (PHP_ROOT / "autorelease/control.py").read_text() + assert_true("supported-versions.php" in registry, "the authoritative source registry left the control surface") + source = control_package_source() + assert_true( + not any(item in source for item in FORBIDDEN_CLASSIFIER_MARKERS), + "source-format handling became a lifecycle parser", + ) return ["evidence-manifest.json"] def a12(self, directory: pathlib.Path) -> list[str]: diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index df9c0e4..fe745d3 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -723,8 +723,8 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): # branch, which git refuses while the recovery worktree still holds it. Line # continuations are folded first, or a call could hide --repo's absence by # wrapping its arguments onto the next line. - folded = re.sub(r"\\\n\s*", " ", recovery) - calls = re.findall(r"^\s*gh pr\s+(?:merge|close)\s.*$", folded, re.MULTILINE) + folded = re.sub(r"\\\n[^\S\n]*", " ", recovery) + calls = re.findall(r"^\s*gh\s+pr\s+(?:merge|close)\s.*$", folded, re.MULTILINE) self.assertEqual(2, len(calls)) for call in calls: self.assertIn('--repo "${{ github.repository }}"', call) From 295845756aad10cb292534c1e8d155a472a1d2e7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:05:32 +0300 Subject: [PATCH 37/48] docs: correct autorelease references and document unattended lifecycle The supported-branch range was hardcoded as "8.2 through 8.5" and the status section listed exact release tags, neither of which anything regenerates. Both now point at the generated policy and the releases page. docs/release-process.md described a human tagging a release and a workflow that rebuilds from the tag. No such trigger exists: autorelease-publish.yml is workflow_dispatch only, so a hand-pushed tag published nothing. It now describes the automated transaction, and keeps the human steps to the recipe change that feeds it. AUTORELEASE.md pointed at docs/autorelease-verification.md, which is not a checked-in file; the verifier writes it into its --output directory. It also now records two invariants the gates already enforce: harness code is protected while product code stays agent-admissible, and validation runs repo scripts at the sealed model commit precisely because protected paths cannot be in the patch. A new "Unattended lifecycle" section states that any major or minor needs zero human input, and that EOL delists a branch without making any published release less installable. Also correct the admin-state snapshot filenames in both repos to the -after convention actually committed, and drop the claim that PHP 8 anchored validators need review; none remain. --- AUTORELEASE.md | 52 ++++++++++++++++++++++++++++++++++--- README.md | 31 ++++++++++++---------- docs/release-process.md | 39 +++++++++++++++++++++++----- docs/repository-settings.md | 2 +- 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/AUTORELEASE.md b/AUTORELEASE.md index 65264b4..1ce3236 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -19,6 +19,17 @@ paths admitted by the evidence-bound plan. It has no GitHub write credential and cannot change the prompts, contracts, workflows, policy, admission, sealing, merge, or release controls. +The line between the two is deliberate. The *harness* is protected: `scripts/` +gates such as `test.sh`, `lib.sh`, `build.sh`, `package.sh`, and +`compare-modules.sh`, the toolchain pins `.spc-version` and `.spc-sha256`, +`tests/`, `autorelease/**`, `schemas/**`, `.github/workflows/**`, and the +pinned Codex prompts and contracts under `.github/`. The *product* is +agent-admissible: `patches/`, `stages/`, `craft.yml`, `extensions.txt`, and +`expected-modules/`, and in `mise-php` the equivalent `hooks/*.lua`, `lib/`, +and `metadata.lua`. A model may change what is built, never what decides +whether the build was correct, so the protected tests are the standing control +on every product change. + ```mermaid flowchart TD capture["Capture fixed raw evidence"] --> changed{"Digest or health changed?"} @@ -43,6 +54,14 @@ and never overwrites, deletes, or retags a published release. A first release on a new PHP branch also requires exact-commit `php_bin_ready` and `mise_ready` records. +Validation deliberately runs the repository's own scripts at the sealed model +commit: `autorelease-implement.yml`, and `autorelease-consumer.yml` in +`mise-php`, check out the base SHA, apply the sealed patch, and run +`./scripts/test.sh` from that tree. That is safe precisely because the gates +themselves are protected paths: a model patch that touched `autorelease/**`, +`tests/`, or any gate script is rejected at admission and never reaches +validation, so the code under test can never be the code doing the testing. + Failures use one deduplicated issue per action key, assigned to the username in `AUTORELEASE_OWNER`. Only a meaningful state, evidence, fingerprint, required action, or final-result change adds a comment. Critical failures stop mutation. @@ -61,6 +80,30 @@ flowchart TD issue --> actions["Actions failure email fallback"] ``` +## Unattended lifecycle + +Adding or retiring a PHP branch takes zero human input. Nothing in the system +is anchored to a particular major or minor: the action keys, version +validators, and policy files all accept any `.`, so PHP `8.6`, +`9.0`, and `10.0` all travel the same path with no code change. + +When upstream evidence first shows a new branch, the admitted implementation +patch adds `expected-modules/.txt` and whatever recipe inputs the +staged S0–S4 builds need, `support-policy.json` regenerates from the accepted +policy, and `mise-php` regenerates `support-snapshot.json` and +`lib/policy.lua` from it. The readiness and event records then merge on their +own: `autorelease-events/`, `autorelease-state/`, and `mise-php`'s +`readiness/` sit outside CODEOWNERS precisely so their exact-SHA automation +PRs satisfy branch protection without a reviewer, while every protected +control still cannot. Publication waits only on machine facts — matching +`php_bin_ready` and `mise_ready` records at exact commits. + +Retirement is the mirror image and equally unattended. Captured EOL evidence +stops new builds and publication for that branch and delists it from +`mise ls-remote` and branch-shorthand resolution. It removes nothing: every +release already published stays immutable, and an exact version such as +`8.2.29` installs exactly as before, indefinitely. + Unattended mutation is controlled by `.github/autorelease-operator.json`. Set `unattendedMutation` to `paused` in a reviewed protected-path PR to stop implementation, merge, and release while @@ -104,9 +147,12 @@ protected `main`; feature-branch runs cannot enter its credentialed environment. Inspect `autorelease-events/`, generated `support-policy.json`, the reviewed -`autorelease/policy-invariants.json`, retained workflow -artifacts, the event issue marker, and `docs/autorelease-verification.md` to -reconstruct a decision. `scripts/snapshot-github-admin-state` captures settings, +`autorelease/policy-invariants.json`, retained workflow artifacts, the event +issue marker, and the `autorelease-verification.json` report and +`autorelease-verification.md` summary that `scripts/verify-autorelease-system` +writes into its `--output` directory, to reconstruct a decision. It is +generated per run and is not a checked-in file. +`scripts/snapshot-github-admin-state` captures settings, variables, and secret names without secret values. Recovery never skips admission or a failed gate: correct the external dependency or submit a reviewed protected-control change, then rerun the normal workflow. diff --git a/README.md b/README.md index bbb5ba9..af98369 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,12 @@ server, DNS, databases, or a desktop UI. ## Status -Public macOS arm64 releases are available for every maintained PHP branch: -[8.2.32](https://github.com/bigpixelrocket/php-bin/releases/tag/8.2.32), -[8.3.32](https://github.com/bigpixelrocket/php-bin/releases/tag/8.3.32), -[8.4.23](https://github.com/bigpixelrocket/php-bin/releases/tag/8.4.23), and -[8.5.9](https://github.com/bigpixelrocket/php-bin/releases/tag/8.5.9). -Each release is rebuilt on macOS 26 arm64 and published only after its exact -module baseline and deployment target checks pass. +Public macOS arm64 releases are available for every maintained PHP branch. See +[the releases page](https://github.com/bigpixelrocket/php-bin/releases) for the +current set; it is published automatically, so any list repeated here would go +stale on the next patch. Each release is rebuilt on macOS 26 arm64 and +published only after its exact module baseline and deployment target checks +pass. ## Autorelease @@ -96,15 +95,17 @@ resolve extension compatibility and exact-module drift without weakening a gate. Publication waits for readiness records tied to the same action key, evidence digests, php-bin policy commit, and exact repository commits. -A new major such as PHP `9.0` follows the same process, but every validator and -parser anchored to PHP 8 must be reviewed explicitly. +A new major such as PHP `9.0` follows the same process unchanged: no validator, +regular expression, or policy file is anchored to PHP 8, so any maintained +major and minor is admissible without a code change. ### End-of-life branches -When captured upstream evidence shows EOL, publication for that branch stops -and coordinated admitted changes remove its shorthand and active build support. -Existing GitHub Releases remain immutable and exact historical installation -continues to work. +When captured upstream evidence shows EOL, the same unattended path stops new +publication for that branch and delists it: admitted changes remove its +shorthand and active build support in both repositories. Nothing is deleted or +retracted. Every already-published GitHub Release stays immutable, and exact +historical installation of those versions keeps working indefinitely. Runtime packages required by particular extensions are documented in [`docs/runtime-deps.md`](docs/runtime-deps.md). The build and release workflow @@ -114,7 +115,9 @@ is documented in [`docs/release-process.md`](docs/release-process.md). - macOS 26 (Tahoe) or newer - arm64 / aarch64 -- Currently supported PHP branches: 8.2 through 8.5 +- Supported PHP branches: whichever branches + [`support-policy.json`](support-policy.json) currently lists, which the + autorelease system regenerates from upstream lifecycle evidence - CLI SAPI Other operating systems, Intel Macs, and PHP 7.x are outside the v1 target. diff --git a/docs/release-process.md b/docs/release-process.md index 3a71c6e..3aa84c5 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,16 +1,43 @@ # Release process +Releases are published by the autorelease system, not by a person. There is no +tag-triggered release workflow: `autorelease-publish.yml` only runs through +`workflow_dispatch` with an admitted action key, exact merged commit, and the +investigation run holding the retained evidence. Pushing a version tag by hand +therefore publishes nothing. See [`AUTORELEASE.md`](../AUTORELEASE.md) for the +full contract. + +## What the automation does + +1. The daily watcher captures upstream evidence and, when it changes, admits an + evidence-bound plan. +2. For an ordinary stable patch the plan requires no edit and goes straight to + the publish transaction. A recipe change is implemented offline, sealed, + validated in a clean checkout, and merged through exact-SHA admission first. +3. The publish transaction rebuilds on macOS 26 arm64, verifies the exact + module baseline and deployment target, packages the archive, writes + `SHA256SUMS`, creates the annotated tag and draft, verifies the draft bytes + through a temporary install, then publishes the unchanged bytes and verifies + the public install through `mise-php`. +4. It advances one legal state at a time, never rebuilds under an existing tag, + and never overwrites, deletes, or retags a published release. + +A first release on a new PHP branch additionally waits for exact-commit +`php_bin_ready` and `mise_ready` records. + +## Changing the recipe by hand + +A human changes what gets built, never how it gets released: + 1. Update `expected-modules/.txt` only from a reviewed module baseline. 2. Update the recipe and run the exact module comparison on macOS arm64. The build gate must report a macOS 26.0 deployment target. 3. Confirm `scripts/test.sh` and public-language checks pass. 4. Open a pull request with the build log and module diff. -5. After approval and merge, create and push a version tag such as `8.4.5`. - Use `8.4.5-1` for a recipe-only rebuild of the same PHP patch. -6. The release workflow rebuilds from the tag, verifies modules, packages the - archive, writes `SHA256SUMS`, and creates the GitHub Release. -7. Verify the release asset names and run an installation through `mise-php` - before announcing the release. + +After that merges, the next admitted rebuild picks it up. Use a rebuild +revision such as `8.4.5-1` when the PHP patch is unchanged but the recipe +changes the bytes. Never upload a locally built replacement over an existing release asset. A changed recipe or artifact requires a new rebuild revision. diff --git a/docs/repository-settings.md b/docs/repository-settings.md index 4a04b37..ff32003 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -69,7 +69,7 @@ The normal verification commands are: ```bash ./scripts/snapshot-github-admin-state \ --repo bigpixelrocket/php-bin \ - --output docs/admin-state/php-bin.json + --output docs/admin-state/php-bin-after.json ./scripts/configure-github-autorelease \ --repo bigpixelrocket/php-bin \ From 096495170a4aba2c90f50b2a4d712dfe540f64a1 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:20:13 +0300 Subject: [PATCH 38/48] fix: set the validate output only after its artifact uploads The consumer workflow in mise-php was corrected to record passed=true as its last step, but php-bin's validate job kept the original shape: it set the output inside the check step, before the bundle step and both uploads. The merge job gates on that output and then downloads the validated artifact, so a bundle or upload failing after the output was set advertised a patch that is missing or incomplete. Merge dies loudly when it reads the absent validation.json, but the two repositories disagreed about an invariant one of them now documents. The check step 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. validate-repair needs no equivalent: merge gates on that job's own result and its upload is already the last step. --- .github/workflows/autorelease-implement.yml | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index 1bd2f72..a620101 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -164,19 +164,19 @@ jobs: test "$(./autorelease/control.py digest autorelease-run/sealed/sealed.patch)" = "$(jq -r .patchDigest autorelease-run/sealed/patch-manifest.json)" git apply --index autorelease-run/sealed/sealed.patch - name: Run authoritative checks and retain failure logs - id: checks + id: run-checks run: | set +e ./scripts/test.sh 2>&1 | tee autorelease-run/authoritative-checks.log 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: Record validated SHA and tree - if: steps.checks.outputs.passed == 'true' + if: steps.run-checks.outputs.status == 'passed' env: BASE_SHA: ${{ needs.preflight.outputs.base_sha }} run: | @@ -190,7 +190,7 @@ jobs: jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" '{headSha:$headSha,tree:$tree,checks:{"Script checks":"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: validated-autorelease-patch-${{ github.run_id }} path: autorelease-run/ @@ -198,13 +198,23 @@ 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: 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: the merge job keys on this output and then downloads + # the validated artifact, so a failed bundle or upload must leave `passed` + # unset rather than advertise a patch that is missing or incomplete. An unset + # output reads as not-passed to both merge and repair. validate-repair needs + # no equivalent: merge gates on that job's own result, and its upload is + # already the last step. + - name: Record that the patch validated + id: checks + if: steps.run-checks.outputs.status == 'passed' + run: echo "passed=true" >> "$GITHUB_OUTPUT" repair: name: One bounded offline repair From c8589aab269449ce868f16fb8e20d558b4fff575 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:20:21 +0300 Subject: [PATCH 39/48] test: scan nested control modules for classifier markers The A04 and A11 marker scan globbed autorelease/*.py, which covers the package as it is today but would miss autorelease/parsers/foo.py. Sub-packaging is exactly the move that made this scan necessary, so the hole it leaves is the one already found once. rglob closes it; the module-name assertion below still holds because it tests names, not paths. Verified by adding a nested autorelease/parsers/lifecycle.py naming the markers: A04 and A11 both fail, and both pass again once it is removed. --- autorelease/verify.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/autorelease/verify.py b/autorelease/verify.py index 5978a69..31ba57e 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -59,8 +59,10 @@ def control_package_source() -> str: `verify.py` is excluded because it is the harness, not a control: it names the forbidden markers to assert their absence and would otherwise fail on itself. """ + # rglob, not glob: sub-packaging the controls is exactly the kind of move that + # made this scan necessary, and a nested module must not fall out of it. modules = sorted( - path for path in (PHP_ROOT / "autorelease").glob("*.py") if path.name != "verify.py" + path for path in (PHP_ROOT / "autorelease").rglob("*.py") if path.name != "verify.py" ) assert_true( {"control.py", "_admission.py", "_evidence.py", "_state.py", "_validation.py"} From 09a03affc4e1626030f3d697ef29bb6d5a4cfd8b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:20:30 +0300 Subject: [PATCH 40/48] docs: make the automation the subject of the rebuild revision release-process.md and README.md still told the reader to "use" a rebuild revision such as 8.4.5-1. Nobody can: the revision is a field of the admitted recipe_rebuild:: action key, proposed by the plan and validated at admission. Leaving the imperative reinstated a human step three lines after the same document says a hand-pushed tag publishes nothing. Also correct the version named in the "still installs" promise. Both AUTORELEASE.md copies used 8.2.29, which was never published; the branch's published tag is 8.2.32. Split the verification-artifact sentence so "it" has one antecedent, and keep the protected-versus-admissible list to this repository's own paths, pointing at mise-php's AUTORELEASE.md for the consumer side rather than duplicating a list that nothing keeps in sync. --- AUTORELEASE.md | 18 +++++++++--------- README.md | 4 ++-- docs/release-process.md | 6 ++++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/AUTORELEASE.md b/AUTORELEASE.md index 1ce3236..535a5dc 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -25,10 +25,10 @@ gates such as `test.sh`, `lib.sh`, `build.sh`, `package.sh`, and `tests/`, `autorelease/**`, `schemas/**`, `.github/workflows/**`, and the pinned Codex prompts and contracts under `.github/`. The *product* is agent-admissible: `patches/`, `stages/`, `craft.yml`, `extensions.txt`, and -`expected-modules/`, and in `mise-php` the equivalent `hooks/*.lua`, `lib/`, -and `metadata.lua`. A model may change what is built, never what decides +`expected-modules/`. A model may change what is built, never what decides whether the build was correct, so the protected tests are the standing control -on every product change. +on every product change. `mise-php` draws the same line over its own paths; +its `AUTORELEASE.md` owns that list. ```mermaid flowchart TD @@ -102,7 +102,7 @@ Retirement is the mirror image and equally unattended. Captured EOL evidence stops new builds and publication for that branch and delists it from `mise ls-remote` and branch-shorthand resolution. It removes nothing: every release already published stays immutable, and an exact version such as -`8.2.29` installs exactly as before, indefinitely. +`8.2.32` installs exactly as before, indefinitely. Unattended mutation is controlled by `.github/autorelease-operator.json`. Set `unattendedMutation` to `paused` in a @@ -147,11 +147,11 @@ protected `main`; feature-branch runs cannot enter its credentialed environment. Inspect `autorelease-events/`, generated `support-policy.json`, the reviewed -`autorelease/policy-invariants.json`, retained workflow artifacts, the event -issue marker, and the `autorelease-verification.json` report and -`autorelease-verification.md` summary that `scripts/verify-autorelease-system` -writes into its `--output` directory, to reconstruct a decision. It is -generated per run and is not a checked-in file. +`autorelease/policy-invariants.json`, retained workflow artifacts, and the +event issue marker to reconstruct a decision. `scripts/verify-autorelease-system` +writes `autorelease-verification.json` and `autorelease-verification.md` into +its `--output` directory; both are per-run artifacts, not checked-in files. + `scripts/snapshot-github-admin-state` captures settings, variables, and secret names without secret values. Recovery never skips admission or a failed gate: correct the external dependency or submit a diff --git a/README.md b/README.md index af98369..424287e 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ requests. For an ordinary stable patch, the admitted no-edit intent goes directly to `Autorelease publish transaction`; no implementation job or PR is created. A recipe change uses a sealed automation PR first. Never move an existing tag or -replace a published asset. Use a rebuild tag such as `8.5.9-1` when the PHP -patch is unchanged but the recipe changes the bytes. +replace a published asset. When the PHP patch is unchanged but the recipe +changes the bytes, the admitted plan requests a rebuild tag such as `8.5.9-1`. ### New PHP branch diff --git a/docs/release-process.md b/docs/release-process.md index 3aa84c5..ac3269b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -35,9 +35,11 @@ A human changes what gets built, never how it gets released: 3. Confirm `scripts/test.sh` and public-language checks pass. 4. Open a pull request with the build log and module diff. -After that merges, the next admitted rebuild picks it up. Use a rebuild +After that merges, the next admitted plan picks it up and requests a rebuild revision such as `8.4.5-1` when the PHP patch is unchanged but the recipe -changes the bytes. +changes the bytes. The revision is a field of the admitted +`recipe_rebuild::` action key, so it is proposed by the plan and +validated at admission, never chosen by hand. Never upload a locally built replacement over an existing release asset. A changed recipe or artifact requires a new rebuild revision. From e34872cfec6108fa8ba958706426863c4d709581 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:48:56 +0300 Subject: [PATCH 41/48] fix(autorelease): defer the no-change evidence record after a recovery merge A recovery merge moves main inside the coordinate job, and the no-change evidence commit asserts an untouched base exactly as the EOL completion does. The trusted-automation exemption is anchored to the run head SHA, so after a recovery the evidence PR no longer matches it, the admission assert fails the job, and the next scheduled run sees the same manifest delta and repeats the failure. Defer the evidence record to that next run instead, which re-derives everything from the moved main. --- autorelease/_state.py | 6 +++--- tests/test_autorelease.py | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/autorelease/_state.py b/autorelease/_state.py index f0068c0..360854b 100644 --- a/autorelease/_state.py +++ b/autorelease/_state.py @@ -326,9 +326,9 @@ def routed(route: str, reason: str, notify: str = "none") -> dict[str, Any]: return routed("none", "no_admitted_plan") if action == WATCH_RECOVERY_ACTION: return routed("none", "recovery_routed_by_recovery_route") - if recovery_merged and action == "branch_eol": - # The completion asserts an untouched base, which the recovered record just moved. - return routed("none", "eol_completion_deferred_by_recovery") + if recovery_merged and action in {"branch_eol", "no_change"}: + # Both routes commit against an untouched base, which the recovered record just moved. + return routed("none", "record_write_deferred_by_recovery") if action == "no_change" and evidence_recorded: return routed("none", "evidence_state_already_recorded") if action in {"blocked", "needs_human"}: diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index fe745d3..6832bf6 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -515,9 +515,15 @@ def route(**decision): self.assertEqual("none", route()["route"]) self.assertEqual("no_admitted_plan", route(action="none")["reason"]) self.assertEqual( - "eol_completion_deferred_by_recovery", + "record_write_deferred_by_recovery", route(action="branch_eol", recoveryMerged=True)["reason"], ) + # A recovery merge moves main mid-run, so the no-change evidence record — which + # also commits against an untouched base — waits for the next scheduled run + # rather than wedging the evidence PR against a base the exemption cannot match. + deferred_no_change = route(action="no_change", recoveryMerged=True) + self.assertEqual("none", deferred_no_change["route"]) + self.assertEqual("record_write_deferred_by_recovery", deferred_no_change["reason"]) self.assertEqual( "evidence_state_already_recorded", route(action="no_change", evidenceAlreadyRecorded=True)["reason"], From 2b7d9becace7ce7cc72e537ec1941f7f3ede8075 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:49:02 +0300 Subject: [PATCH 42/48] refactor(autorelease): read the missing record after the health guards The unrecorded-release lookup was computed before the branches that decide whether it may be used at all, against the guard-first shape of the rest of the decision. Behaviour is unchanged: an unhealthy capture already yielded an empty release list. --- autorelease/_state.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/autorelease/_state.py b/autorelease/_state.py index 360854b..6eb57f2 100644 --- a/autorelease/_state.py +++ b/autorelease/_state.py @@ -221,7 +221,6 @@ def watch_decision( for event in events if event.get("state") != "complete" ) - unrecorded = unrecorded_published_release(releases, events, record_files) if not health.get("healthy", False): trigger = "health_failed" elif any(capture.get("status") != 200 for capture in manifest.get("captures", [])): @@ -256,7 +255,14 @@ def watch_decision( # called: the repair is deterministic, but suppressing the investigation would let a # blocked repair starve reconciliation and selection on every later run. model_call = trigger != "quiet" - if unrecorded and trigger not in {"health_failed", "source_unhealthy"}: + # An untrustworthy snapshot cannot be read for a missing record either, so the + # repair is only looked for once the health guards above have passed. + unrecorded = ( + None + if trigger in {"health_failed", "source_unhealthy"} + else unrecorded_published_release(releases, events, record_files) + ) + if unrecorded: trigger = "record_missing" return { "schemaVersion": 1, From b32afa0792032383132f0e9c1678c15bf4b3fd7c Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:49:10 +0300 Subject: [PATCH 43/48] fix(autorelease): protect the shared dependabot configuration in php-bin .github/dependabot.yml is on mise-php's shared-file manifest and is protected and code-owned there, but php-bin's patterns fell through it entirely. Any change to it on php-bin's main breaks byte parity and hard-fails mise-php's consumer on the first step of every scheduled run, and only a human can re-sync the protected copy. A09 now asserts every manifest path is protected in both repositories, using mise-php's own admission module for its half, so the asymmetry is a gate failure rather than a review miss. --- .github/CODEOWNERS | 1 + autorelease/protected-paths.json | 1 + autorelease/verify.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 977664a..707af5f 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 /.github/autorelease-operator.json @loadinglucian diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index 1f9f91a..db18ec4 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/autorelease-operator.json", ".github/autorelease-pins.json", ".github/workflows/**", diff --git a/autorelease/verify.py b/autorelease/verify.py index 31ba57e..4a46ba6 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -26,6 +26,7 @@ instruction_digest, mutation_allowed, notification_decision, + path_is_protected, release_transition, retry_decision, seal_patch, @@ -622,11 +623,37 @@ def a09(self, directory: pathlib.Path) -> list[str]: cwd=self.mise_root, check=False, ) assert_true(quiet.returncode != 0, "mise-php names a record file for a quiet run") + # mise-php's byte-parity gate fails closed on the first step of every consumer + # run when a shared file drifts, and only a human can re-sync its protected copy. + # A shared file that either repository lets an agent rewrite is therefore a + # cross-repository stall, and neither repository's own tests can see it: each + # checks the manifest against its own pattern list alone. The verdicts come from + # mise-php's own admission module so a rewritten matcher still has to answer. + shared_paths = json.loads((self.mise_root / "autorelease/shared-files.json").read_text())["paths"] + assert_true(bool(shared_paths), "the shared-file manifest is empty, so it gates nothing") + mise_protection = json.loads( + run( + "python3", "-c", + "import json, sys; sys.path.insert(0, '.'); " + "from autorelease.admission import protected; " + "print(json.dumps({path: protected(path) for path in json.loads(sys.argv[1])}))", + json.dumps(shared_paths), + cwd=self.mise_root, + ).stdout + ) + for path in shared_paths: + assert_true(path_is_protected(path), f"php-bin does not protect shared file {path}") + assert_true(mise_protection[path], f"mise-php does not protect shared file {path}") (directory / "coordination.json").write_bytes(canonical_json(result)) (directory / "action-filenames.json").write_bytes( canonical_json({key: action_filename(key) for key in action_keys}) ) - return ["coordination.json", "action-filenames.json"] + (directory / "shared-file-protection.json").write_bytes( + canonical_json( + {path: {"php-bin": path_is_protected(path), "mise-php": mise_protection[path]} for path in shared_paths} + ) + ) + return ["coordination.json", "action-filenames.json", "shared-file-protection.json"] def a10(self, directory: pathlib.Path) -> list[str]: releases = (self.mise_root / "lib/releases.lua").read_text() From bb27c0d53741172ddc85e34990f2c808473d6dcc Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:49:16 +0300 Subject: [PATCH 44/48] docs: drop the committed hardening plan from the release repository The plan narrates gate weaknesses, verification shortcuts, and the trust boundary in detail, which is permanent disclosure and permanent noise in a public release-artifact repository. It stays with the rest of the SDD material outside the tree. --- ...-08-03-autorelease-unattended-hardening.md | 744 ------------------ 1 file changed, 744 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md diff --git a/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md b/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md deleted file mode 100644 index cb06a51..0000000 --- a/docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md +++ /dev/null @@ -1,744 +0,0 @@ -# Autorelease Unattended Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix every finding from the thermo-nuclear review of the php-bin + mise-php autorelease system and guarantee fully unattended `new_patch`, `new_branch` (minor or major), and `branch_eol` releases with zero human input, while keeping every historical release installable. - -**Architecture:** Two repos. `php-bin` (publisher, `/Users/lucian/Developer/bigpixelrocket/php-bin`) holds the deterministic core (`autorelease/control.py`, `autorelease/verify.py`) plus GitHub Actions workflows that let a Codex agent propose changes which deterministic Python admits, seals, and merges. `mise-php` (consumer, `/Users/lucian/Developer/bigpixelrocket/mise-php`) is a mise plugin in Lua whose autorelease consumer (`autorelease/consumer.py`, `autorelease/admission.py`) propagates php-bin policy. The plan closes automation deadlocks, removes hardcoded 8.x version assumptions, fixes verified defects, plugs authority holes, converts brittle text-grep verification to structural checks, dedupes copy-pasted admission logic, and makes post-publish transactions recoverable. - -**Tech Stack:** Python 3 stdlib (no new deps), Bash + jq, GitHub Actions, Lua (vfox/mise plugin API), `unittest`. - -## Global Constraints - -- Never read, write, search, or reference `**/auth.json`, `**/.env`, `**/.env.*`, `~/.ssh/**`, `~/.aws/**` in any command or code. -- No AI attribution anywhere: no "Generated with", no "Co-Authored-By", nothing referencing AI in code, comments, commits, or PRs. -- Never commit to `main`/`master`. All work on branch `fix/autorelease-unattended-hardening` in each repo. Conventional Commits (`fix:`, `feat:`, `refactor:`, `test:`, `docs:`, `chore:`). -- Never move or delete published tags, releases, or release assets. EOL means "stop producing new builds", never "remove old ones". -- Every user-facing string added must pass `scripts/check-public-language.sh` (runs in both repos' `scripts/test.sh`). -- php-bin gate: `./scripts/test.sh` (the "Script checks" required check). mise-php gate: `./scripts/test.sh` (the "Plugin contract" required check; requires macOS arm64 + `mise` installed — both true on this machine). -- Behavior-preserving refactors and behavior changes go in separate commits. -- After all merges: verify with `./scripts/verify-autorelease-system` in php-bin. -- Merging: use standing admin-bypass approval — verify functional checks first, squash merge, immediately restore any temporarily relaxed protection (for these repos: lift `enforce_admins`, merge, restore). - ---- - -## Phase 0 — Branches - -### Task 0: Create working branches - -**Files:** none (git only) - -- [ ] **Step 1:** In `php-bin`: `git checkout main && git pull && git checkout -b fix/autorelease-unattended-hardening` -- [ ] **Step 2:** In `mise-php`: `git checkout main && git pull && git checkout -b fix/autorelease-unattended-hardening` -- [ ] **Step 3:** Copy this plan into `php-bin/docs/superpowers/plans/` (already there), `git add docs/superpowers/plans/2026-08-03-autorelease-unattended-hardening.md && git commit -m "docs: add autorelease unattended hardening plan"` - ---- - -## Phase 1 — Unattended functional guarantee - -### Task 1: mise-php — snapshot-driven maintained branches in Lua - -The listing filter `version:match("^8%.[2-5]%.%d+$")` in `lib/releases.lua` and `content:match("(8%.[2-5][^%s]*)")` in `hooks/parse_legacy_file.lua` hardcode branches. A `new_branch:8.6` or `new_branch:9.0` release would never be listed by `mise ls-remote php`, and `branch_eol` would keep listing dead branches. Intended semantics (already asserted by `scripts/test.sh`): **maintained branches are listed; EOL/old versions stay installable via exact version**. - -Fix: generate `lib/policy.lua` from `support-snapshot.json:maintainedBranches`, and have `releases.lua` build its filter from it. `parse_legacy_file.lua` becomes version-agnostic (exact installs of any version are allowed). - -**Files:** -- Create: `mise-php/scripts/generate-policy-lua` -- Create: `mise-php/lib/policy.lua` (generated) -- Modify: `mise-php/lib/releases.lua` (`is_supported_version`) -- Modify: `mise-php/hooks/parse_legacy_file.lua:9` -- Modify: `mise-php/scripts/test.sh` (sync check + generic-branch listing test) - -**Interfaces:** -- Produces: `lib/policy.lua` returning `{ maintained = { "8.2", "8.3", "8.4", "8.5" } }`; `scripts/generate-policy-lua` (no args, reads `support-snapshot.json`, writes `lib/policy.lua`, idempotent). -- Consumed by: Task 6 (admission cross-check), Task 15 (docs). - -- [ ] **Step 1: Write the generator** — `mise-php/scripts/generate-policy-lua`, mode 0755: - -```bash -#!/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 -``` - -- [ ] **Step 2: Generate** — run `./scripts/generate-policy-lua`; confirm `lib/policy.lua` contains the four branches from `support-snapshot.json`. -- [ ] **Step 3: Rewrite `is_supported_version`** in `mise-php/lib/releases.lua` — replace: - -```lua -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 -end -``` - -with: - -```lua -local policy = require("policy") - -function M.is_supported_version(version) - 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 -``` - -(`local policy = require("policy")` goes at the top with the other requires.) - -- [ ] **Step 4: Make legacy-file parsing version-agnostic** — in `mise-php/hooks/parse_legacy_file.lua` replace `local version = content:match("(8%.[2-5][^%s]*)")` with `local version = content:match("(%d+%.%d+[^%s]*)")`. -- [ ] **Step 5: Add the sync check to `scripts/test.sh`** — after the `validate-structured-output-schemas` line add: - -```bash -"$SCRIPT_DIR/generate-policy-lua" -git -C "$PROJECT_ROOT" diff --exit-code lib/policy.lua -``` - -- [ ] **Step 6: Add a generic-branch listing test to `scripts/test.sh`** — the mock server serves whatever archives exist in the assets dir. After the existing `8.1.99` EOL assertions, extend the fixture with a hypothetical next branch to prove listing follows the snapshot, not the code. Immediately after `cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$EOL_ARCHIVE_NAME"` add: - -```bash -FUTURE_ARCHIVE_NAME="php-9.0.1-cli-macos-aarch64.tar.gz" -cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$FUTURE_ARCHIVE_NAME" -``` - -update the `shasum` line to include `"$FUTURE_ARCHIVE_NAME"`, and after the `8.1.99` listing check add: - -```bash -# 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" -``` - -- [ ] **Step 7: Run** `./scripts/test.sh` — expect "Plugin contract test passed." (check the mock server exposes the new archive; if the server only lists archives found on disk this works as-is — read `test/mock_server.py` and if it hardcodes release JSON, extend its fixture list with `9.0.1` the same way `8.1.99` is included). -- [ ] **Step 8: Commit** — `git add lib/policy.lua lib/releases.lua hooks/parse_legacy_file.lua scripts/generate-policy-lua scripts/test.sh test/mock_server.py && git commit -m "feat: derive maintained branches from support snapshot in plugin"` - -### Task 2: mise-php — require policy.lua regeneration in admitted diffs - -Unattended propagation: when the consumer's admitted patch updates `support-snapshot.json`, admission must also require a matching `lib/policy.lua` in the same diff, or a stale filter ships silently. - -**Files:** -- Modify: `mise-php/autorelease/admission.py` (inside the `path == "support-snapshot.json"` branch of the diff validator, around line 339) -- Test: `mise-php/test/test_autorelease.py` - -**Interfaces:** -- Consumes: `lib/policy.lua` format from Task 1 (`maintained = { "", ... }`). - -- [ ] **Step 1: Write the failing test** in `mise-php/test/test_autorelease.py` (match the file's existing fixture-building style — read its existing diff-admission test first and clone its setup): - -```python -def test_snapshot_diff_requires_matching_policy_lua(self): - # Build a valid admitted diff that touches support-snapshot.json but - # leaves lib/policy.lua stale; admission must reject it. - ... # use the file's existing helper that assembles a passing diff case, - # change maintainedBranches to ["8.3", "8.4", "8.5", "8.6"], - # keep lib/policy.lua listing the old branches - with self.assertRaises(admission.AdmissionError) as ctx: - admission.validate_patch(...) # same call the sibling test makes - self.assertIn("policy.lua", str(ctx.exception)) -``` - -(The exact helper names must be copied from the neighboring snapshot test in that file — mirror it exactly; the deliverable is: stale `lib/policy.lua` + changed snapshot ⇒ `AdmissionError` mentioning `policy.lua`.) - -- [ ] **Step 2: Run it** — `python3 -m unittest test.test_autorelease -k policy_lua` — expect FAIL (no error raised). -- [ ] **Step 3: Implement** — in `admission.py`, inside the `if path == "support-snapshot.json":` branch after the snapshot JSON is parsed, add: - -```python -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") -``` - -- [ ] **Step 4: Run the test** — expect PASS. Then run the full suite: `python3 -m unittest discover -s test -p 'test_*.py'`. -- [ ] **Step 5: Commit** — `git commit -am "feat: reject snapshot diffs with stale policy.lua"` - -### Task 3: mise-php — unattended readiness-record merges (deadlock fix) - -`autorelease-consumer.yml` creates a readiness PR touching `readiness/*` — a protected path — but `protected-controls.yml` has **no** automation exemption, so the required "Protected controls" check demands an exact-head owner review. Every consumer run therefore stalls on a human. Port php-bin's trusted-automation exemption pattern (`protected-controls.yml`, the `autorelease-events/*` branch) for readiness records. - -**Files:** -- Modify: `mise-php/autorelease/admission.py` (add `validate_readiness_record`) -- Modify: `mise-php/.github/workflows/protected-controls.yml` (add exemption before the owner-approval fallback) -- Test: `mise-php/test/test_autorelease.py` - -**Interfaces:** -- Produces: `admission.validate_readiness_record(record: dict) -> None` raising `AdmissionError` on any deviation from the shape produced by `consumer.readiness()` (`consumer.py:300-336`). - -- [ ] **Step 1: Write the failing tests**: - -```python -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) -``` - -- [ ] **Step 2: Run** — expect FAIL with `AttributeError: ... no attribute 'validate_readiness_record'`. -- [ ] **Step 3: Implement** in `admission.py`: - -```python -READINESS_RECORD_KEYS = { - "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", - "policyDigest", "policyInvariantsDigest", "misePhpCommit", - "evidenceDigests", "recordedAt", -} - - -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") -``` - -- [ ] **Step 4: Run tests** — expect PASS; run the full unittest suite. -- [ ] **Step 5: Add the workflow exemption** — in `mise-php/.github/workflows/protected-controls.yml`, inside the inline Python after `if not protected: ... SystemExit(0)`, insert (mirroring php-bin's event exemption at `php-bin/.github/workflows/protected-controls.yml:225-260`, including its imports `base64`, `re`, `sys` and the `api_one` helper — copy `api_one` from php-bin verbatim): - -```python -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) -``` - -The step also needs the workflow's env to expose `BASE_SHA`, `HEAD_REF`, `HEAD_REPOSITORY`, `PR_AUTHOR` (copy the exact `env:` keys from php-bin's protected-controls step) and the inline Python needs `head_ref`, `head_repo`, `author`, `base` variables plus `sys.path.insert(0, ".")` / `from autorelease.admission import AdmissionError, validate_readiness_record` — mirror how php-bin's script imports `validate_completed_event_record` from `autorelease.control`. - -- [ ] **Step 6: Static check** — `python3 -c "import yaml"` is unavailable; instead run `ruby -ryaml -e 'YAML.load_file(".github/workflows/protected-controls.yml")'` to confirm the YAML parses, then `./scripts/test.sh`. -- [ ] **Step 7: Commit** — `git commit -am "feat: admit trusted automation readiness records without owner review"` - -### Task 4: php-bin — remove 8.x assumptions from the build/verify path - -`scripts/build.sh:93-97` special-cases `^8\.[2-5]$`. Everything else (ACTION_KEY_RE, seal, events) is already major-agnostic — verified. A `new_branch` patch adds `expected-modules/.txt` (unprotected path — admissible by the runtime agent). - -**Files:** -- Modify: `php-bin/scripts/build.sh:93-97` -- Test: `php-bin/tests/test_autorelease.py` (plan admission for future branches) - -- [ ] **Step 1: Fix build.sh** — replace: - -```bash - PHP_MINOR="${PHP_VERSION%.*}" - if [[ "$PHP_VERSION" =~ ^8\.[2-5]$ ]]; then - PHP_MINOR="$PHP_VERSION" - fi -``` - -with: - -```bash - PHP_MINOR="${PHP_VERSION%.*}" - if [[ "$PHP_VERSION" =~ ^[0-9]+\.[0-9]+$ ]]; then - PHP_MINOR="$PHP_VERSION" - fi -``` - -- [ ] **Step 2: Write the future-branch admission test** in `php-bin/tests/test_autorelease.py` — clone the file's existing `validate_plan` happy-path test (the one using `new_patch:8.5.9`) and parameterize: - -```python -def test_future_branch_action_keys_admitted(self): - for key in ("new_patch:8.6.1", "new_patch:9.0.1", "new_branch:8.6", - "new_branch:9.0", "branch_eol:8.2:2026-12-31"): - self.assertIsNotNone(control.ACTION_KEY_RE.fullmatch(key), key) -``` - -- [ ] **Step 3: Run** — `python3 -m unittest tests.test_autorelease -k future_branch` — expect PASS (regex already generic; this is a regression pin, not TDD red). -- [ ] **Step 4: Run** `./scripts/test.sh` (full php-bin gate). -- [ ] **Step 5: Commit** — `git commit -am "fix: accept any maintained branch in stage-4 module comparison"` - ---- - -## Phase 2 — Verified defects and authority holes - -### Task 5: mise-php — fix the dead secret-scanner arm - -`admission.py:337`: `r"...|github_pat_|\\bsk-[A-Za-z0-9_-]{20,}"` — `\\b` inside a raw string is literal backslash+b, so the `sk-` arm never matches. php-bin's `control.py:65-70` has it right. - -**Files:** -- Modify: `mise-php/autorelease/admission.py:337` -- Test: `mise-php/test/test_autorelease.py` - -- [ ] **Step 1: Failing test** (drive through the module-level regex so the test doesn't need a full diff fixture — extract the pattern to a module constant first, matching php-bin's `SECRET_PATTERNS` style): - -```python -def test_secret_scanner_catches_sk_tokens(self): - self.assertIsNotNone(admission.SECRET_RE.search("key = sk-" + "a" * 24)) - self.assertIsNotNone(admission.SECRET_RE.search("github_pat_x")) - self.assertIsNone(admission.SECRET_RE.search("task-" + "a" * 24)) -``` - -- [ ] **Step 2: Run** — expect FAIL (`SECRET_RE` missing). -- [ ] **Step 3: Implement** — near `ACTION_KEY_RE` add: - -```python -SECRET_RE = re.compile( - r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" - r"|github_pat_" - r"|\bsk-[A-Za-z0-9_-]{20,}" -) -``` - -and replace the inline `re.search(r"-----BEGIN ...", text)` at line 337 with `SECRET_RE.search(text)`. - -- [ ] **Step 4: Run** — sk-token test passes; full suite passes. -- [ ] **Step 5: Commit** — `git commit -am "fix: repair secret scanner word boundary for sk tokens"` - -### Task 6: Both repos — protect the gate harness, regenerate CODEOWNERS - -The admitted runtime agent can currently edit `scripts/test.sh`, `tests/**`, `scripts/build.sh`, `scripts/package.sh`, `scripts/compare-modules.sh` in php-bin (all return `path_is_protected(...) == False`), i.e. it can rewrite the very gates that admit it. CODEOWNERS has also drifted (missing `/scripts/dispatch-pr-checks`, `/scripts/serve-autorelease-artifact`, `/scripts/verify-autorelease-system`). - -Ordering caution: protecting `tests/*` means unattended patches can never edit tests — Task 4 already made the test suite branch-generic, so `new_branch`/`branch_eol` need no test edits. Verify that holds before protecting. - -**Files:** -- Modify: `php-bin/autorelease/protected-paths.json` (add `scripts/test.sh`, `scripts/build.sh`, `scripts/package.sh`, `scripts/compare-modules.sh`, `scripts/check-public-language.sh`, `tests/*`) -- Modify: `php-bin/.github/CODEOWNERS` -- Modify: `mise-php/autorelease/protected-paths.json` (add `scripts/test.sh`, `scripts/check-public-language.sh`, `test/*`, `scripts/consume-php-policy`, `scripts/generate-policy-lua`) -- Modify: `mise-php/.github/CODEOWNERS` -- Test: `php-bin/tests/test_autorelease.py`, `mise-php/test/test_autorelease.py` - -- [ ] **Step 1: Failing test, php-bin**: - -```python -def test_gate_harness_paths_are_protected(self): - for path in ("scripts/test.sh", "scripts/build.sh", "scripts/package.sh", - "scripts/compare-modules.sh", "scripts/check-public-language.sh", - "tests/test_autorelease.py"): - self.assertTrue(control.path_is_protected(path), path) - -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) -``` - -- [ ] **Step 2: Run** — expect FAIL. -- [ ] **Step 3: Implement** — append the new patterns to `php-bin/autorelease/protected-paths.json` `patterns` array; add the missing exact-path lines to `.github/CODEOWNERS` using the same `@owner` as its existing lines (read the file; every line follows `/path @bigpixelrocket-owner-handle`). Add lines for every non-glob pattern currently missing, including the three drifted scripts. -- [ ] **Step 4: Run** php-bin suite + `./scripts/test.sh`. -- [ ] **Step 5: Repeat for mise-php** — same test shape against `admission`'s protected checker (`admission.py` exposes the `protected()`/pattern logic — mirror how its existing protected-path test calls it), same JSON+CODEOWNERS edits with mise-php's path list from **Files** above. -- [ ] **Step 6: Sanity-check unattended flows still admissible** — run existing seal/admission tests in both repos; the runtime patch surface for `new_patch` (`downloads/`-adjacent build inputs, `expected-modules/*`, `support-policy.json` special case) must not intersect the new protections. `python3 -m unittest discover` in both repos. -- [ ] **Step 7: Commit (each repo)** — `git commit -am "fix: protect gate harness scripts and tests from admitted patches"` - -### Task 7: mise-php — restore validator parity with php-bin - -mise-php's `scripts/validate-structured-output-schemas` lost php-bin's non-scalar-`const` rejection and the schema↔constants cross-check; consequently `schemas/implementation-plan.schema.json` carries an array `const` (line ~75) and an unpatterned `actionKey` (line ~8) that php-bin's stricter validator would reject. - -**Files:** -- Modify: `mise-php/scripts/validate-structured-output-schemas` (port the two checks from `php-bin/scripts/validate-structured-output-schemas:54-55` and `:82-105`, adjusted to mise-php's schema/constants module names) -- Modify: `mise-php/schemas/implementation-plan.schema.json` (replace the array `const` with `items`+`enum` the way php-bin's plan schema does; add `"pattern"` to `actionKey` matching `ACTION_KEY_RE`'s source with anchors) -- Test: the validator script itself is the test — it runs in `scripts/test.sh` - -- [ ] **Step 1:** Port the checks (copy php-bin's code blocks; adjust import paths — mise-php constants live in `autorelease/admission.py`/`consumer.py`). -- [ ] **Step 2:** Run `./scripts/validate-structured-output-schemas` — expect FAIL on the two schema defects. -- [ ] **Step 3:** Fix the schema (array const → per-item enum; actionKey pattern anchored `^...$` — derive by copying the regex source string from `admission.py` and verifying with `python3 -c` that both agree on `new_patch:9.0.1`). -- [ ] **Step 4:** Run `./scripts/test.sh` — pass. -- [ ] **Step 5: Commit** — `git commit -am "fix: restore schema validator parity with php-bin"` - ---- - -## Phase 3 — Structural verification instead of text-grep - -### Task 8: php-bin — extract merge-admission check assertions into one script - -Four hand-written jq assertion blocks (watch.yml:277-278, watch.yml:360-361, implement.yml:398+502, publish.yml:361) drifted: watch asserts both "Script checks" and "Protected controls" buckets; implement/publish assert only "Script checks". The implement/publish divergence is *currently required* (sealed patches legitimately touch protected `support-policy.json`, admitted by seal verification, so their protected-controls bucket may be red) — make that divergence declared, not accidental. - -**Files:** -- Create: `php-bin/scripts/assert-admission-checks` -- Modify: `php-bin/.github/workflows/autorelease-watch.yml`, `autorelease-implement.yml`, `autorelease-publish.yml` (replace 4 inline blocks + wire the new script), `php-bin/autorelease/protected-paths.json` (+ CODEOWNERS via Task 6's sync test) -- Modify: `mise-php/scripts/` gets the same script (Task 12 sync manifest covers byte-parity); replace the jq assert in `autorelease-consumer.yml` -- Test: `php-bin/tests/test_autorelease.py` runs the script against fixture JSON - -- [ ] **Step 1: Write the script** — `php-bin/scripts/assert-admission-checks`, mode 0755: - -```bash -#!/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" -checks_file="" -while [[ $# -gt 0 ]]; do - case "$1" in - --require-protected-controls) require_protected="true"; shift ;; - --checks) checks_file="$2"; shift 2 ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac -done -[[ -n "$checks_file" ]] -jq -e '[.[] | select(.name=="Script checks") | .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)." -``` - -- [ ] **Step 2: Test it** in `tests/test_autorelease.py`: - -```python -def test_assert_admission_checks(self): - ok = [{"name": "Script checks", "bucket": "pass"}, - {"name": "Protected controls", "bucket": "pass"}] - missing_protected = [{"name": "Script checks", "bucket": "pass"}] - with tempfile.TemporaryDirectory() as tmp: - path = pathlib.Path(tmp, "checks.json") - path.write_text(json.dumps(ok)) - subprocess.run(["scripts/assert-admission-checks", "--checks", str(path), - "--require-protected-controls"], check=True) - path.write_text(json.dumps(missing_protected)) - subprocess.run(["scripts/assert-admission-checks", "--checks", str(path)], check=True) - result = subprocess.run(["scripts/assert-admission-checks", "--checks", str(path), - "--require-protected-controls"], capture_output=True) - self.assertNotEqual(result.returncode, 0) -``` - -- [ ] **Step 3: Run test** — PASS. -- [ ] **Step 4: Replace the four inline blocks** — watch.yml's two sites call `./scripts/assert-admission-checks --require-protected-controls --checks `; implement.yml's two and publish.yml's one call it without the flag. Keep each site's `` argument as whatever JSON the surrounding step already produced. Replace mise-php's `jq -e '[.[] | select(.name=="Plugin contract")...` occurrences in `autorelease-consumer.yml` with a mise-php copy of the script whose required check name is parameterized: add `--check-name "Plugin contract"` support (default `"Script checks"`) — one more `case` arm and `--arg` in the jq filter: - -```bash -jq -e --arg name "$check_name" '[.[] | select(.name==$name) | .bucket] == ["pass"]' "$checks_file" > /dev/null -``` - -- [ ] **Step 5:** Add `scripts/assert-admission-checks` to both repos' `protected-paths.json` + CODEOWNERS (Task 6's sync test enforces the latter). -- [ ] **Step 6:** Run both repos' `./scripts/test.sh`; `ruby -ryaml -e 'YAML.load_file(...)'` on each edited workflow. -- [ ] **Step 7: Commit (each repo)** — `git commit -am "refactor: unify merge admission check assertions in one script"` - -### Task 9: php-bin — convert verify.py text assertions to structural checks - -`verify.py` pins workflow *source text*: `release_workflow.count("current-operator.json") >= 3` (:634), `"Unattended mutation is paused" in watch_workflow` (:626), exact `cp .codex/...config.toml` strings (a07), jq literal formatting (:540-547), and `codex-action-contract.json`'s `expectedInvocations` counts string occurrences. These break on any refactor (including this plan's) without catching real regressions. - -**Files:** -- Modify: `php-bin/autorelease/verify.py` (generalize `load_workflow` usage; rewrite the listed assertions) -- Modify: `php-bin/.github/codex-action-contract.json` + its checker if it counts strings (read `scripts/validate-codex-action-inputs` first) -- Test: `./scripts/verify-autorelease-system` (verify.py *is* the test) - -- [ ] **Step 1:** Read `verify.py` assertions a00–a20 and list every assertion that greps YAML source text (the four above plus any found). -- [ ] **Step 2:** For each, rewrite against `load_workflow()` (existing ruby-backed parser at `verify.py:57-65`) — the structural form asserts on parsed steps. Pattern to follow (real example for :626): - -```python -watch = load_workflow(".github/workflows/autorelease-watch.yml") -gate_steps = [ - step - for job in watch["jobs"].values() - for step in job.get("steps", []) - if "unattendedMutation" in (step.get("run") or "") -] -require(gate_steps, "watch workflow must gate on the operator unattended state") -``` - -and for :634 (operator preconditions in publish): - -```python -publish = load_workflow(".github/workflows/autorelease-publish.yml") -operator_steps = [ - step - for job in publish["jobs"].values() - for step in job.get("steps", []) - if "current-operator.json" in (step.get("run") or "") -] -require(len(operator_steps) >= 2, "publish workflow must capture and assert operator state") -``` - -The invariant each assertion protects (operator pause honored; evidence captured; config copied before agent start) must be stated in the `require` message — assert presence and job placement, not byte counts. - -- [ ] **Step 3:** If `expectedInvocations` in `codex-action-contract.json` is enforced by counting substrings, change the checker to count parsed workflow steps whose `uses:` matches the Codex action, and update the contract numbers to match reality per workflow. -- [ ] **Step 4:** Run `./scripts/verify-autorelease-system` — all acceptance checks pass. -- [ ] **Step 5:** Mutation-test one assertion: temporarily rename the operator step in a scratch copy of publish.yml and confirm the structural check fails, then revert. -- [ ] **Step 6: Commit** — `git commit -am "refactor: assert workflow structure instead of source text in verifier"` - -### Task 10: mise-php — same conversion for its text asserts - -`mise-php/test/test_autorelease.py:92-96` asserts jq literals in workflow source; completion-criteria IDs are authored as jq string literals 3× in `autorelease-consumer.yml` while php-bin has a `CRITERIA` table in `scripts/prepare-agent-task:13-80`. - -**Files:** -- Create: `mise-php/scripts/prepare-agent-task` (port php-bin's, with mise-php's criteria IDs — copy the exact IDs from the three jq literals in `autorelease-consumer.yml`) -- Modify: `mise-php/.github/workflows/autorelease-consumer.yml` (replace the three inline criteria constructions with `./scripts/prepare-agent-task` calls, same argument style as php-bin's implement workflow uses) -- Modify: `mise-php/test/test_autorelease.py:92-96` (assert against the script's emitted JSON, not workflow source text) - -- [ ] **Step 1:** Read `php-bin/scripts/prepare-agent-task` fully; read the three criteria sites in `autorelease-consumer.yml`. -- [ ] **Step 2:** Write `mise-php/scripts/prepare-agent-task` mirroring php-bin's structure with mise-php's criteria table. -- [ ] **Step 3:** Failing test: rewrite `test_autorelease.py:92-96` to run `./scripts/prepare-agent-task` for each action kind and assert the criteria IDs in its JSON output (exact IDs copied from the current jq literals). -- [ ] **Step 4:** Wire the workflow; `ruby -ryaml` parse check; `./scripts/test.sh`. -- [ ] **Step 5:** Add `scripts/prepare-agent-task` to mise-php `protected-paths.json` (+ CODEOWNERS). -- [ ] **Step 6: Commit** — `git commit -am "refactor: emit agent task criteria from one script"` - ---- - -## Phase 4 — Transaction recovery (publish atomicity) - -### Task 11: php-bin — resumable post-publish event record - -`autorelease-publish.yml` publishes the immutable release (:281-291) then opens a separate event-record PR (:345-369); a failure between the two leaves a live release with no completed event, and the notify job (:406-444) keys on job result, screaming "critical" even when the release itself succeeded. The watcher already owns a trusted-automation record pattern (`autorelease/eol-complete-*` branches). Extend the watcher to detect *published release missing its completed event record* and file the record itself. - -**Files:** -- Modify: `php-bin/autorelease/control.py` (`watch_decision` — new decision branch) -- Modify: `php-bin/.github/workflows/autorelease-watch.yml` (route the new decision to the same record-PR steps used for eol-complete; branch name `autorelease/event-` matches the existing protected-controls exemption which already accepts `autorelease/(event|eol-complete)-` — **but** its `expected_workflow` maps `event-` to publish.yml, so extend that mapping: watcher-recovered records also arrive on `eol-complete`-style branches; simplest correct move: reuse the `eol-complete` branch prefix for recovery records, which protected-controls already trusts from watch.yml with `schedule`/`workflow_dispatch` events) -- Test: `php-bin/tests/test_autorelease.py` - -- [ ] **Step 1: Failing test** — read `watch_decision`'s existing tests, then add: - -```python -def test_watch_flags_published_release_missing_event_record(self): - # Evidence shows tag 8.5.9 published; event store has no completed - # new_patch:8.5.9 record; watcher must decide to file the record, - # not to start a new release. - decision = control.watch_decision(...) # mirror the sibling test's fixtures, - # with release present + record absent - self.assertEqual(decision["action"], "record_completed_event") - self.assertEqual(decision["actionKey"], "new_patch:8.5.9") -``` - -(The exact fixture shape comes from the neighboring `watch_decision` tests — the deliverable: release-exists-and-record-missing ⇒ `record_completed_event`, ranked before any new-release decision.) - -- [ ] **Step 2: Run** — FAIL (unknown action). -- [ ] **Step 3: Implement** the branch in `watch_decision` (before new-release selection): if evidence proves a published tag whose action key has no completed event record, return `{"action": "record_completed_event", "actionKey": ...}`. -- [ ] **Step 4:** Wire watch.yml: route `record_completed_event` through the existing eol-complete record steps (same `./autorelease/control.py` event-record invocation publish uses, same PR/merge/exemption path on an `autorelease/eol-complete-` branch). The routing change lands in Task 13's extracted router — if executing in order, add it to the router table there; if this task runs first, add a plain `elif` now and migrate in Task 13. -- [ ] **Step 5:** Re-key the publish notify job on transaction state: replace its `if: failure()` (or result-based condition) so it distinguishes "release not published" (critical) from "release published, record pending — watcher will recover" (warning). Concretely: publish writes `autorelease-run/transaction.json` with `{"released": true/false}` after the release step; notify reads it via `actions/download-artifact` and picks the message. Keep the message wording compliant with `check-public-language.sh`. -- [ ] **Step 6:** `./scripts/test.sh`; `ruby -ryaml` parse of watch.yml + publish.yml. -- [ ] **Step 7: Commit** — `git commit -m "feat: recover missing event records from the watcher" && git commit` (split: control.py+tests as `feat:`, workflow wiring as separate `feat:` commit if both large). - ---- - -## Phase 5 — Dedup, dead code, hygiene - -### Task 12: Cross-repo shared-file sync gate - -~20 files are duplicated across repos; 15 have drifted silently. Declare the intended-identical set and gate on it where the network exists (the consumer workflow already fetches php-bin at an exact commit). - -**Files:** -- Create: `mise-php/autorelease/shared-files.json` — list of repo-relative paths intended byte-identical with php-bin (start with: `scripts/dispatch-pr-checks`, `scripts/assert-admission-checks`, `scripts/check-public-language.sh`, plus any file the review found byte-identical today; **exclude** legitimately divergent files) -- Modify: `mise-php/.github/workflows/autorelease-consumer.yml` — in the preflight job (where `current-support-policy.json` is fetched), add a step fetching each shared file at the pinned php-bin commit and comparing digests: - -```bash -jq -r '.paths[]' autorelease/shared-files.json | while read -r path; do - gh api "repos/bigpixelrocket/php-bin/contents/$path?ref=$PHP_BIN_COMMIT" \ - --jq .content | tr -d '\n' | base64 -d > "$RUNNER_TEMP/shared-file" - if ! cmp -s "$RUNNER_TEMP/shared-file" "$path"; then - echo "Shared file drifted from php-bin: $path" >&2 - exit 1 - fi -done -``` - -- Test: `mise-php/test/test_autorelease.py` — `shared-files.json` parses, is sorted, every listed path exists. - -- [ ] **Step 1:** Diff the candidate shared files between repos (`diff php-bin/scripts/dispatch-pr-checks mise-php/scripts/dispatch-pr-checks` etc.); byte-sync the ones that should match (copy php-bin's canonical version over mise-php's), listing each in `shared-files.json`. -- [ ] **Step 2:** Failing test for manifest shape; implement; PASS. -- [ ] **Step 3:** Add the workflow step; `ruby -ryaml` parse; `./scripts/test.sh` in mise-php. -- [ ] **Step 4: Commit** — `git commit -am "feat: gate consumer runs on shared-file parity with php-bin"` - -### Task 13: php-bin — extract the watch dispatch and operator gate - -`watch.yml:211-378` is a 168-line if/elif that silently `exit 0`s on unrouted action combinations (e.g. `repair` with `editsRequired:false`); the operator pause gate is inlined 7× in 3 shapes while `control.mutation_allowed()` sits unreachable; `tr ':/' '--'` filename mapping has 8 definitions. - -**Files:** -- Modify: `php-bin/autorelease/control.py` — add `route_watch_action(decision: dict) -> dict` returning `{"route": "", "actionKey": ...}` and raising `ControlError` on unrouted combinations; add CLI subcommands `route-watch-action`, `operator-gate` (wraps `mutation_allowed`), `action-filename` (wraps the existing `str.maketrans` mapping) -- Modify: `php-bin/.github/workflows/autorelease-watch.yml` — the dispatch becomes: call `./autorelease/control.py route-watch-action` once, then a short `case "$route" in ... esac` with an explicit `*) echo "unrouted action" >&2; exit 1` default -- Modify: all 7 operator-gate inline sites (watch/implement/publish) — replace with `./autorelease/control.py operator-gate --operator-file ` -- Modify: all 8 `tr ':/' '--'` sites — replace with `"$(./autorelease/control.py action-filename "$ACTION_KEY")"` (including mise-php's copies; its Python sites import the one helper from `consumer.py`, which drops the duplicated `ACTION_KEY_RE` in `admission.py` by importing it from `consumer`) -- Test: `php-bin/tests/test_autorelease.py` - -- [ ] **Step 1: Failing tests**: - -```python -def test_route_watch_action_covers_every_decision(self): - # One assertion per legal decision shape, plus: - with self.assertRaises(control.ControlError): - control.route_watch_action({"action": "repair", "editsRequired": False}) - -def test_action_filename(self): - self.assertEqual(control.action_filename("branch_eol:8.2:2026-12-31"), - "branch_eol-8.2-2026-12-31.json") - -def test_operator_gate_blocks_paused_state(self): - self.assertTrue(control.mutation_allowed({"unattendedMutation": "enabled"})) - self.assertFalse(control.mutation_allowed({"unattendedMutation": "paused"})) -``` - -(Adjust `mutation_allowed`'s exact signature to what `control.py:1030` already defines — wire, don't rewrite.) - -- [ ] **Step 2:** Run — FAIL on the new names. -- [ ] **Step 3:** Implement the three functions/subcommands; enumerate every branch of the current watch.yml:211-378 dispatch into `route_watch_action`'s table, with `ControlError` for anything unrouted (this converts today's silent `exit 0` holes into loud failures — enumerate the legal no-op decisions explicitly as `{"route": "none"}` so genuinely idle runs stay green). -- [ ] **Step 4:** Rewire the three workflows and mise-php sites; `ruby -ryaml` parse all; both `./scripts/test.sh` gates. -- [ ] **Step 5: Commit** — `refactor: route watch actions through deterministic control table` (php-bin), `refactor: reuse canonical action filename helper` (mise-php). - -### Task 14: Both repos — dead code, dead schemas, hygiene sweep - -**Files (php-bin):** -- Delete: `schemas/autorelease-event.schema.json`, `schemas/policy-invariants.schema.json`, `schemas/support-policy.schema.json` (verify zero references first: `grep -rn "" --exclude-dir=.build .`) -- Modify: `schemas/agent-completion-assessment.schema.json` — align its plan-fragment duplicate with the canonical plan schema (same constraints, or reference the shared definition the way sibling schemas do) -- Modify: `autorelease/control.py` — dedupe manifest-digest formula (extract `manifest_digest(captures) -> str` used by both `capture_evidence` and `indexed_captures`); use `COMMIT_SHA_RE` at the three inline re-spellings (:751, :845, :851); delete `retry_decision` and `audit_reconstruction` **only if** `grep -rn` shows no callers outside tests, else leave with a docblock stating the caller -- Modify: all 7 php-bin workflows — add top-level `defaults: run: shell: bash` (gives `pipefail` semantics per GitHub's bash invocation), drop the 4 no-op `permissions:` blocks re-declaring defaults, narrow the publish preflight job's permissions to `contents: read` -- Modify: `scripts/check-public-language.sh` — replace the rg-vs-grep dual scope with `git ls-files -z | xargs -0 grep` in both repos -- Modify: `ci.yml:27` — shellcheck glob covers extensionless scripts: `shellcheck scripts/*.sh scripts/dispatch-pr-checks scripts/assert-admission-checks` (list every extensionless bash script explicitly) -- Modify: `scripts/snapshot-github-admin-state:56-58` — import `canonical_json`/`sha256_bytes` from `autorelease.control` instead of reimplementing -- Modify: `scripts/serve-autorelease-artifact` — add a shutdown path (handle SIGTERM, exit cleanly) -- Modify: `scripts/test.sh` — write `.artifacts` under `${RUNNER_TEMP:-$(mktemp -d)}` instead of the working tree -- Modify: `scripts/lib.sh` — replace blanket `# shellcheck disable=SC2034` with per-line disables on the actually-unused vars - -**Files (mise-php):** -- Delete: `autorelease-events/` dead directory + the consumer workflow's `--events autorelease-events` argument + `consumer.py`'s `event_incomplete` machinery (grep-verify no other callers) -- Modify: `autorelease/admission.py` — `from .consumer import ACTION_KEY_RE` replacing its local copy; change `fnmatch.fnmatch` (:75) to `fnmatch.fnmatchcase` (parity with `protected-controls.yml:84`) -- Modify: `schemas/implementation-plan.schema.json` + `AUTORELEASE.md:34-48` — delete the `notification` field nothing reads and the docs section describing the nonexistent notification subsystem -- Modify: `autorelease-consumer.yml:441` — merge job condition becomes `if: ${{ !cancelled() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].outputs.passed == 'true') }}` with `validate-repair` gaining the same named output `passed` as `validate` (stop keying on `.result`); fix the in-place artifact mutation at :415-416 by writing repaired artifacts to a fresh path -- Modify: workflows — same `defaults: run: shell: bash` sweep - -- [ ] **Step 1:** For every deletion, run the grep proving zero references; paste the empty result into the commit message body. -- [ ] **Step 2:** Make the php-bin edits; run `./scripts/test.sh` + `shellcheck` on every touched script. -- [ ] **Step 3:** Make the mise-php edits; run `./scripts/test.sh`. -- [ ] **Step 4:** Run `./scripts/verify-autorelease-system` in php-bin — the Task 9 structural assertions must still pass after the workflow hygiene edits (this is the point of Task 9 landing first). -- [ ] **Step 5: Commits** — separate commits per concern: `chore: delete unreferenced schemas`, `refactor: dedupe digest and sha validation helpers`, `chore: enforce bash defaults and least privilege in workflows`, `fix: make repair merge condition survive skipped validate job`, etc. - -### Task 15: php-bin — decompose control.py behind a façade - -`control.py` is 1,269 lines with ~6 seams. Split into a package while keeping `autorelease/control.py` as the stable import surface (verify.py, tests, workflows all import/invoke it). - -**Files:** -- Create: `php-bin/autorelease/_validation.py` (require/regex/digest primitives), `_admission.py` (validate_plan + seal_patch + verify_merge), `_state.py` (event/release state machines + watch/route decisions), `_evidence.py` (capture client + indexed_captures) -- Modify: `php-bin/autorelease/control.py` — imports + re-exports + `main()` CLI only; every existing public name still importable as `autorelease.control.` -- Test: existing suite is the safety net — zero test-file edits allowed in this task - -- [ ] **Step 1:** Move code verbatim (no behavior edits — this is the refactor-only commit), wire re-exports. -- [ ] **Step 2:** `python3 -m unittest discover` — all pass untouched. -- [ ] **Step 3:** `./scripts/test.sh` and `./scripts/verify-autorelease-system` — pass. -- [ ] **Step 4:** Confirm `autorelease/*` protected-paths glob covers the new files (it does — same directory). -- [ ] **Step 5: Commit** — `git commit -am "refactor: split control module behind stable facade"` - -Also split `validate_plan`'s ~175-line body (control.py:546-719) into per-concern helpers (`_validate_plan_shape`, `_validate_plan_preconditions`, `_validate_plan_actions`) inside `_admission.py` in a **second** commit, still behavior-preserving, suite green. - ---- - -## Phase 6 — Docs and end-to-end proof - -### Task 16: Docs truth pass + full system verification - -**Files:** -- Modify: `mise-php/AUTORELEASE.md:108` (drop the reference to nonexistent `docs/autorelease-verification.md` or create the file it promises), `AUTORELEASE.md:34-48` (done in Task 14 — verify) -- Modify: `php-bin/docs/repository-settings.md:70-72` (correct the snapshot output names to what `scripts/snapshot-github-admin-state` actually emits) -- Modify: both repos' `AUTORELEASE.md` — add a short "Unattended lifecycle" section documenting: new branch (any major/minor) requires zero human input end-to-end (agent patch adds `expected-modules/.txt`, policy + snapshot + `lib/policy.lua` regenerate, readiness/event records merge via trusted-automation exemptions); EOL stops new builds and delists the branch while all published releases remain installable exactly. -- [ ] **Step 1:** `markdownlint` on every touched `.md`. -- [ ] **Step 2:** php-bin: `./scripts/test.sh && ./scripts/verify-autorelease-system`. mise-php: `./scripts/test.sh`. -- [ ] **Step 3: Commit** — `docs: correct autorelease references and document unattended lifecycle` - -### Task 17: Ship - -- [ ] **Step 1:** Push both branches; open PRs (php-bin and mise-php) titled `fix: autorelease unattended hardening`; PR bodies summarize per-phase changes, no AI attribution. -- [ ] **Step 2:** Wait for functional checks ("Script checks" / "Plugin contract" + CI) to pass on both PRs. Note: these PRs touch protected paths, so "Protected controls" will demand owner review that the owner cannot self-approve — per standing approval: lift `enforce_admins`, squash-merge, restore `enforce_admins` immediately (both repos). -- [ ] **Step 3:** Reply to every review-bot finding on the PRs in friendly plain English (no em-dashes). -- [ ] **Step 4:** After merge, trigger `autorelease-e2e.yml` (php-bin) and `e2e.yml` (mise-php) via `gh workflow run`; confirm green. -- [ ] **Step 5:** Run `gh workflow run autorelease-watch.yml` once and confirm the watcher completes with a clean decision (no-op or legitimate action) with zero human gates. - ---- - -## Self-review notes - -- **Spec coverage:** every review finding maps to a task — verified defects (T3, T5, T6-drift, T8-drift), authority holes (T6), structural verifier regressions (T9, T10), duplication (T8, T10, T12, T13, T14), atomicity (T11), dead code/docs (T14, T16), file size (T15), unattended functional gaps (T1, T2, T3, T4, T11). The `scripts/consume-php-policy` unprotected-sibling finding is folded into T6's mise-php pattern list. -- **Known intentional divergence:** mise-php `expectedInvocations` 3 vs php-bin 4 stays divergent — excluded from T12's shared-file list, handled structurally in T9/T10. -- **Ordering constraints:** T4 (branch-generic tests) before T6 (protect tests/); T9 (structural asserts) before T13/T14 (workflow refactors that would break text asserts); T1 before T2 (policy.lua exists before admission requires it). -- **Fixture-dependent test bodies** (T2 step 1, T11 step 1) intentionally defer exact helper names to the sibling tests in the same file — the acceptance criterion in each is stated precisely; implementers must clone the adjacent test's setup rather than invent fixtures. From 6f59ab3539882420b85b7594f7515ad4a7b1f188 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:49:27 +0300 Subject: [PATCH 45/48] docs(autorelease): bind an in-flight action key across watcher runs The date, attempt, and evidence suffixes of an action key are model-chosen, and the EOL completion step hard-asserts the record file its earlier run opened. Re-deriving a different suffix leaves the original event incomplete forever, so the investigation phase now reuses an incomplete record's key. --- .github/codex/autorelease/investigation.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/codex/autorelease/investigation.md b/.github/codex/autorelease/investigation.md index 9714a08..38c9040 100644 --- a/.github/codex/autorelease/investigation.md +++ b/.github/codex/autorelease/investigation.md @@ -58,7 +58,10 @@ phase-scoped action key in the event contract. It must use one of the reviewed forms enforced by the output schema: `no_change`, `new_patch`, `new_branch`, `branch_eol`, `recipe_rebuild`, `repair`, `source_unhealthy`, `health_failed`, `policy_failure`, or `auth_failure` with the required version, date, attempt, -or lowercase hexadecimal evidence suffix. +or lowercase hexadecimal evidence suffix. When `autorelease-events/` already +holds an incomplete record for the same branch, reuse that record's `actionKey` +verbatim instead of re-deriving its date, attempt, or evidence suffix, so the +run that completes the action names the file the earlier run opened. Every `completionAssessment.criteria[].evidence` entry is a machine-resolved reference, never explanatory prose. Use only `evidence[N]` for an item in the From ca9cd9b1dd6e0ffe0af14400ebb9be70ca569c4f Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 19:49:27 +0300 Subject: [PATCH 46/48] test(autorelease): validate the jq-built recovery record as a completed event The recovery record is assembled by four jq programs in the watcher and was only judged by the protected-controls evaluator at merge time, so a field drifting out of one of them wedged a live run instead of failing a test. The programs are asserted to still be the workflow's own text, then run for real through the same three transitions and fed to validate_completed_event_record. --- tests/test_autorelease.py | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 6832bf6..d8c44eb 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -738,6 +738,81 @@ def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): self.assertIn("release-transaction-state-${{ github.run_id }}", release) self.assertIn("jq -r .released release-state/transaction-state.json", release) + def test_the_jq_built_recovery_record_validates_as_a_completed_event(self): + # The recovery record is assembled by four `jq -n` programs in the watcher and + # was only ever judged by the protected-controls evaluator at merge time, so a + # field drifting out of one of those programs surfaced as a wedged PR on a live + # run rather than as a failing test. The programs are asserted to still be the + # workflow's own text and then run for real, so this test moves with the + # workflow or fails. + root = pathlib.Path(__file__).resolve().parents[1] + watcher = (root / ".github/workflows/autorelease-watch.yml").read_text() + recovery = watcher[ + watcher.index("- name: Recover the event record of a published release"): + watcher.index("- name: Prepare deterministic no-change evidence") + ] + record_program = ( + '{schemaVersion:1,actionKey:$actionKey,classification:$classification,' + 'state:"release_requested",history:[],phpBinCommit:$commit,' + 'evidenceManifestDigest:$evidenceManifestDigest,recoveredByRunId:$runId}' + ) + released_program = ( + '[{kind:"published_immutable_release",version:$version,phpBinCommit:$commit,' + 'attestationDigest:$attestation,assetDigests:' + '{("php-"+$version+"-cli-macos-aarch64.tar.gz"):$archive,"SHA256SUMS":$checksums}}]' + ) + verified_program = '[{kind:"public_release_bytes_reverified",version:$version,modes:["public_download"]}]' + complete_program = '[{kind:"record_recovered_by_watcher",runId:$runId}]' + for program in (record_program, released_program, verified_program, complete_program): + self.assertIn(program, recovery) + + def jq(program, **args): + argv = ["jq", "-n"] + for name, value in args.items(): + argv += ["--arg", name, value] + return subprocess.run(argv + [program], capture_output=True, text=True, check=True).stdout + + version = "8.5.9" + commit = "c" * 40 + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + event = work / "recovered-event.json" + evidence = work / "recovery-evidence.json" + output = work / "recovered-event.next" + event.write_text( + jq( + record_program, + actionKey=f"new_patch:{version}", + classification="new_patch", + commit=commit, + runId="4242", + evidenceManifestDigest="sha256:" + "d" * 64, + ) + ) + transitions = ( + ("released", lambda: jq( + released_program, + version=version, + commit=commit, + archive="sha256:" + "a" * 64, + checksums="sha256:" + "b" * 64, + attestation="sha256:" + "e" * 64, + )), + ("public_install_verified", lambda: jq(verified_program, version=version)), + ("complete", lambda: jq(complete_program, runId="4242")), + ) + for target, build in transitions: + self.assertIn(f"--target {target}", recovery) + evidence.write_text(build()) + subprocess.run( + [str(root / "scripts/autorelease-event"), + "--event", str(event), "--target", target, + "--evidence", str(evidence), "--output", str(output)], + capture_output=True, check=True, + ) + output.replace(event) + validate_completed_event_record(json.loads(event.read_text())) + def test_assert_admission_checks(self): script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/assert-admission-checks") ok = [{"name": "Script checks", "bucket": "pass"}, From 5e5d99f93d923b73d16c960205ce3deeb2d7af38 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 20:30:52 +0300 Subject: [PATCH 47/48] 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 From 2b22061f829d91d84c05ce63b47e587737660ee5 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Mon, 3 Aug 2026 20:32:51 +0300 Subject: [PATCH 48/48] test: anchor codeowners pairing on the repo root and tolerate tabs --- tests/test_autorelease.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index d8c44eb..0956266 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -644,11 +644,12 @@ def test_gate_harness_paths_are_protected(self): self.assertTrue(path_is_protected(path), path) 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() + root = pathlib.Path(__file__).resolve().parents[1] + patterns = json.loads((root / "autorelease/protected-paths.json").read_text())["patterns"] + codeowners = (root / ".github/CODEOWNERS").read_text() for pattern in patterns: if "*" not in pattern: - self.assertIn(f"/{pattern} ", codeowners, pattern) + self.assertRegex(codeowners, rf"(?m)^/{re.escape(pattern)}\s", pattern) def test_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1]