diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 469cf85..8f5a82a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,12 +1,21 @@ /.github/codex/ @loadinglucian /.github/codex-action-contract.json @loadinglucian +/.github/dependabot.yml @loadinglucian /.github/workflows/ @loadinglucian /.github/CODEOWNERS @loadinglucian /.codex/ @loadinglucian /autorelease/ @loadinglucian /schemas/ @loadinglucian /scripts/admit-autorelease-plan @loadinglucian +/scripts/assert-admission-checks @loadinglucian +/scripts/dispatch-pr-checks @loadinglucian +/scripts/prepare-agent-task @loadinglucian /scripts/seal-autorelease-patch @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian /scripts/validate-structured-output-schemas @loadinglucian /scripts/verify-merge-admission @loadinglucian +/scripts/test.sh @loadinglucian +/scripts/check-public-language.sh @loadinglucian +/scripts/consume-php-policy @loadinglucian +/scripts/generate-policy-lua @loadinglucian +/test/ @loadinglucian diff --git a/.github/codex/autorelease/implementation.md b/.github/codex/autorelease/implementation.md index e3b8310..b261eef 100644 --- a/.github/codex/autorelease/implementation.md +++ b/.github/codex/autorelease/implementation.md @@ -5,6 +5,8 @@ inside only admitted paths, and leave a diff ready for deterministic sealing and clean validation. Use no web or shell network. Run and record all advisory checks. Do not change -protected or unadmitted paths. Return GO only when all criteria pass, the local -support behavior matches the accepted php-bin policy, and unresolved is empty. +protected or unadmitted paths. When the edit changes `support-snapshot.json`, +run `scripts/generate-policy-lua` and include the regenerated `lib/policy.lua` +in the same diff. Return GO only when all criteria pass, the local support +behavior matches the accepted php-bin policy, and unresolved is empty. Do not commit, push, merge, tag, publish, or record readiness yourself. diff --git a/.github/codex/autorelease/investigation.md b/.github/codex/autorelease/investigation.md index 9c0db47..fda6b07 100644 --- a/.github/codex/autorelease/investigation.md +++ b/.github/codex/autorelease/investigation.md @@ -5,8 +5,10 @@ with the exact local support snapshot, then produce one evidence-bound plan without modifying the repository. Identify whether local parsing, filtering, fixtures, documentation, temporary -artifact installation, or readiness state must change. Cite exact public policy -commit and digests. Do not independently fetch or classify upstream PHP data. +artifact installation, or readiness state must change. A `support-snapshot.json` +edit also regenerates `lib/policy.lua`, so admit both paths in the same plan. +Cite exact public policy commit and digests. Do not independently fetch or +classify upstream PHP data. Return GO only when every criterion passes and unresolved is empty. Treat `requiredChecks` as downstream exact-head gates, not investigation-phase diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 3523ebe..ae42b05 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -12,6 +12,10 @@ concurrency: group: mise-php-autorelease-consumer cancel-in-progress: false +defaults: + run: + shell: bash + jobs: investigate: runs-on: ubuntu-latest @@ -40,6 +44,33 @@ jobs: test "$state" = "paused" || test "$state" = "enabled" echo "state=$state" >> "$GITHUB_OUTPUT" echo "commit=$(git -C php-operator-control rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Gate the run on shared-file parity with php-bin + env: + GH_TOKEN: ${{ github.token }} + PHP_BIN_COMMIT: ${{ steps.operator.outputs.commit }} + run: | + mapfile -t shared < <(jq -r '.paths[]' autorelease/shared-files.json) + # mapfile reports success even when the substitution failed, so an + # absent, unparseable, or empty manifest would disable the gate. + if [[ "${#shared[@]}" -eq 0 ]]; then + echo "Shared-file manifest is missing, unparseable, or empty: autorelease/shared-files.json" >&2 + exit 1 + fi + for path in "${shared[@]}"; do + gh api "repos/Bigpixelrocket/php-bin/contents/$path?ref=$PHP_BIN_COMMIT" \ + > "$RUNNER_TEMP/shared-file.json" + # An oversized or symlinked entry comes back unencoded, which would + # otherwise decode to nothing and be reported as drift. + if [[ "$(jq -r .encoding "$RUNNER_TEMP/shared-file.json")" != "base64" ]]; then + echo "Shared file was not returned base64-encoded by php-bin at $PHP_BIN_COMMIT: $path" >&2 + exit 1 + fi + jq -r .content "$RUNNER_TEMP/shared-file.json" | base64 --decode > "$RUNNER_TEMP/shared-file" + if ! cmp -s "$RUNNER_TEMP/shared-file" "$path"; then + echo "Shared file drifted from php-bin at $PHP_BIN_COMMIT: $path" >&2 + exit 1 + fi + done - name: Capture accepted public php-bin policy run: | mkdir -p autorelease-run @@ -48,7 +79,7 @@ jobs: --invariants-output autorelease-run/policy-invariants.json \ --commit-output autorelease-run/php-bin-main.json \ --manifest autorelease-run/policy-capture.json - - name: Compare only opaque policy and event digests + - name: Compare only opaque policy digests id: compare run: | ./scripts/consume-php-policy compare \ @@ -56,7 +87,6 @@ jobs: --invariants autorelease-run/policy-invariants.json \ --policy-commit autorelease-run/php-bin-main.json \ --snapshot support-snapshot.json \ - --events autorelease-events \ --output autorelease-run/decision.json echo "trigger=$(jq -r .trigger autorelease-run/decision.json)" >> "$GITHUB_OUTPUT" - name: Prepare exact investigation contract @@ -70,6 +100,7 @@ jobs: --arg policyInvariantsDigest "$(jq -r .policyInvariantsDigest autorelease-run/decision.json)" \ --arg phpBinOperatorCommit "${{ steps.operator.outputs.commit }}" \ --arg operatorState "${{ steps.operator.outputs.state }}" \ + --argjson completionCriteria "$(./scripts/prepare-agent-task --phase investigation)" \ '{ contractVersion:1, phase:"investigation", @@ -78,12 +109,7 @@ jobs: preconditions:{misePhpHead:$misePhpHead,phpBinPolicyCommit:$phpBinPolicyCommit,supportPolicyDigest:$supportPolicyDigest,policyInvariantsDigest:$policyInvariantsDigest,phpBinOperatorCommit:$phpBinOperatorCommit,operatorState:$operatorState}, allowedAuthority:["read_repository","read_captured_policy"], nonGoals:["upstream_php_classification","repository_mutation","required_check_execution","irreversible_github_effect"], - completionCriteria:[ - {id:"phase-goal-correct",requirement:"The goal matches exact inputs.",evidenceRequired:"Exact preconditions."}, - {id:"policy-difference-explained",requirement:"Every required local change is bound to captured policy.",evidenceRequired:"Policy digest and JSON locator."}, - {id:"authority-explicit",requirement:"Paths and checks are explicit.",evidenceRequired:"Allowed paths and required checks."}, - {id:"no-unresolved-work",requirement:"No contradiction or stop condition remains.",evidenceRequired:"Empty unresolved list."} - ], + completionCriteria:$completionCriteria, stopConditions:["missing_or_contradictory_policy","changed_precondition","required_protected_change"] }' > autorelease-run/event-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" @@ -166,17 +192,12 @@ jobs: path: autorelease-run - name: Prepare implementation contract and prompt run: | - jq \ + jq --argjson completionCriteria "$(./scripts/prepare-agent-task --phase implementation)" \ '.phase="implementation" | .goal="Implement the admitted mise-php policy synchronization at the exact base." | .allowedAuthority=["workspace_write_admitted_paths","local_advisory_checks"] | .nonGoals=["protected_control_change","irreversible_github_effect"] - | .completionCriteria=[ - {id:"phase-goal-correct",requirement:"Goal and preconditions remain exact.",evidenceRequired:"Plan digest and base SHA."}, - {id:"admitted-diff-complete",requirement:"Diff is complete and admitted.",evidenceRequired:"Final diff and path comparison."}, - {id:"advisory-checks-recorded",requirement:"All checks ran.",evidenceRequired:"Commands and results."}, - {id:"no-unresolved-work",requirement:"No in-scope work remains.",evidenceRequired:"Empty unresolved list."} - ]' \ + | .completionCriteria=$completionCriteria' \ autorelease-run/event-contract.json > autorelease-run/implementation-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" phase="sha256:$(shasum -a 256 .github/codex/autorelease/implementation.md | awk '{print $1}')" @@ -256,7 +277,7 @@ jobs: test "$actual" = "$expected" git apply --index autorelease-run/sealed/sealed.patch - name: Run authoritative plugin checks without OpenAI credential - id: checks + id: run-checks run: | test -z "${OPENAI_API_KEY:-}" set +e @@ -264,12 +285,12 @@ jobs: status="${PIPESTATUS[0]}" set -e if [[ "$status" == "0" ]]; then - echo "passed=true" >> "$GITHUB_OUTPUT" + echo "status=passed" >> "$GITHUB_OUTPUT" else - echo "passed=false" >> "$GITHUB_OUTPUT" + echo "status=failed" >> "$GITHUB_OUTPUT" fi - name: Create reproducible validated commit bundle - if: steps.checks.outputs.passed == 'true' + if: steps.run-checks.outputs.status == 'passed' env: BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | @@ -284,7 +305,7 @@ jobs: '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: steps.checks.outputs.passed == 'true' + if: steps.run-checks.outputs.status == 'passed' with: name: mise-validated-autorelease-patch-${{ github.run_id }} path: autorelease-run/ @@ -292,13 +313,21 @@ jobs: retention-days: 90 include-hidden-files: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: steps.checks.outputs.passed != 'true' + if: steps.run-checks.outputs.status != 'passed' with: name: mise-failed-autorelease-validation-${{ github.run_id }} path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true + # Last step on purpose, exactly as in validate-repair: the merge job keys on + # this output and downloads the validated artifact, so a failed upload must + # leave `passed` unset rather than claim a validated patch is waiting. An + # unset output reads as not-passed to both the merge job and repair. + - name: Record that the patch validated + id: checks + if: steps.run-checks.outputs.status == 'passed' + run: echo "passed=true" >> "$GITHUB_OUTPUT" repair: needs: [investigate, validate] @@ -323,17 +352,14 @@ jobs: git apply --index autorelease-run/sealed/sealed.patch fingerprint="sha256:$(shasum -a 256 autorelease-run/authoritative-checks.log | awk '{print $1}')" jq --arg goal "Repair the retained authoritative failure $fingerprint without expanding the admitted diff." \ + --argjson completionCriteria "$(./scripts/prepare-agent-task --phase repair)" \ '.phase="repair" | .goal=$goal | .allowedAuthority=["workspace_write_admitted_paths","local_advisory_checks"] | .nonGoals=["protected_control_change","irreversible_github_effect"] | .stopConditions=["changed_precondition","repeated_or_unrelated_failure","required_protected_change"] - | .completionCriteria=[ - {id:"phase-goal-correct",requirement:"The repair goal matches the exact failure and inputs.",evidenceRequired:"Failure fingerprint and exact base."}, - {id:"failure-cause-removed",requirement:"The named failure is removed without weakening a gate.",evidenceRequired:"Minimal admitted diff and reproduction evidence."}, - {id:"advisory-checks-recorded",requirement:"All advisory checks ran.",evidenceRequired:"Commands and results."}, - {id:"no-unresolved-work",requirement:"No repeated or unrelated failure remains.",evidenceRequired:"Empty unresolved list."} - ]' autorelease-run/implementation-contract.json > autorelease-run/repair-contract.json + | .completionCriteria=$completionCriteria' \ + autorelease-run/implementation-contract.json > autorelease-run/repair-contract.json shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" phase="sha256:$(shasum -a 256 .github/codex/autorelease/repair.md | awk '{print $1}')" event="sha256:$(shasum -a 256 autorelease-run/repair-contract.json | awk '{print $1}')" @@ -389,6 +415,8 @@ jobs: validate-repair: needs: [investigate, repair] + outputs: + passed: ${{ steps.checks.outputs.passed }} runs-on: macos-26 timeout-minutes: 25 permissions: @@ -412,8 +440,12 @@ jobs: git apply --index autorelease-run/sealed-repair/sealed.patch test -z "${OPENAI_API_KEY:-}" ./scripts/test.sh - cp autorelease-run/sealed-repair/sealed.patch autorelease-run/sealed/sealed.patch - cp autorelease-run/sealed-repair/patch-manifest.json autorelease-run/sealed/patch-manifest.json + # The merge job reads autorelease-run/sealed, so the repaired seal has to + # arrive under that name. The rejected seal is renamed rather than + # overwritten: it is the evidence of what the repair replaced, and it ships + # in the same artifact. + mv autorelease-run/sealed autorelease-run/sealed-failed + cp -R autorelease-run/sealed-repair autorelease-run/sealed - name: Create repaired validated commit bundle env: BASE_SHA: ${{ needs.investigate.outputs.base_sha }} @@ -435,10 +467,15 @@ jobs: if-no-files-found: error retention-days: 90 include-hidden-files: true + # Last step on purpose: the merge job keys on this output, so it must not be + # set until the validated artifact it downloads has actually been uploaded. + - name: Record that the repaired patch validated + id: checks + run: echo "passed=true" >> "$GITHUB_OUTPUT" merge-and-record-readiness: needs: [investigate, validate, validate-repair] - if: always() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].result == 'success') + if: ${{ !cancelled() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].outputs.passed == 'true') }} runs-on: ubuntu-latest timeout-minutes: 25 permissions: @@ -471,7 +508,7 @@ jobs: # action key, but this value reaches $GITHUB_OUTPUT and later shell # steps, so its alphabet is re-asserted at the boundary. [[ "$action_key" =~ ^[A-Za-z0-9._:-]+$ ]] - branch="autorelease/$(printf '%s' "$action_key" | tr ':/' '--')" + branch="autorelease/$(./scripts/consume-php-policy action-filename "$action_key" --suffix '')" gh auth setup-git git push origin "HEAD:refs/heads/$branch" number="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" @@ -491,7 +528,7 @@ jobs: --pr "$PR_NUMBER" \ --check "Plugin contract" \ --output autorelease-run/pr-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/pr-checks.json + ./scripts/assert-admission-checks --check-name "Plugin contract" --checks autorelease-run/pr-checks.json expected="$(jq -r .headSha autorelease-run/validation.json)" actual="$(gh pr view "$PR_NUMBER" --json headRefOid --jq .headRefOid)" test "$actual" = "$expected" @@ -556,7 +593,7 @@ jobs: git checkout -B autorelease/readiness-${{ github.run_id }} origin/main base="$(git rev-parse HEAD)" mkdir -p readiness - filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" + filename="$(./scripts/consume-php-policy action-filename "$ACTION_KEY")" mapfile -t digests < <(jq -r '.captures[].digest' autorelease-run/policy-capture.json) args=() for digest in "${digests[@]}"; do args+=(--evidence-digest "$digest"); done @@ -589,7 +626,7 @@ jobs: --pr "${{ steps.readiness.outputs.number }}" \ --check "Plugin contract" \ --output autorelease-run/readiness-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/readiness-checks.json + ./scripts/assert-admission-checks --check-name "Plugin contract" --checks autorelease-run/readiness-checks.json actual="$(gh pr view "${{ steps.readiness.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "${{ steps.readiness.outputs.head_sha }}" git fetch origin main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e036077..bb5e643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: contract: name: Plugin contract diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cc6491d..9181e3f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: install: name: Install PHP ${{ inputs.version }} diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index b762686..0d1ad7c 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -13,6 +13,10 @@ permissions: contents: read pull-requests: read +defaults: + run: + shell: bash + jobs: protected-controls: name: Protected controls @@ -38,6 +42,9 @@ jobs: echo "number=$PR_NUMBER" echo "head_sha=$(jq -r .head.sha "$RUNNER_TEMP/pr.json")" echo "base_sha=$(jq -r .base.sha "$RUNNER_TEMP/pr.json")" + echo "head_ref=$(jq -r .head.ref "$RUNNER_TEMP/pr.json")" + echo "head_repository=$(jq -r .head.repo.full_name "$RUNNER_TEMP/pr.json")" + echo "author=$(jq -r .user.login "$RUNNER_TEMP/pr.json")" } >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -49,16 +56,26 @@ jobs: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + HEAD_REPOSITORY: ${{ steps.pr.outputs.head_repository }} + PR_AUTHOR: ${{ steps.pr.outputs.author }} PROTECTED_REVIEWER: ${{ vars.AUTORELEASE_OWNER }} run: | python3 - <<'PY' import fnmatch + import base64 import json import os import pathlib + import re import subprocess import sys + sys.path.insert(0, ".") + + from autorelease.admission import AdmissionError, validate_readiness_record + def api(path): result = subprocess.run( ["gh", "api", path, "--paginate", "--slurp"], @@ -71,9 +88,22 @@ jobs: raise RuntimeError("GitHub API returned an invalid paginated response") return [item for page in pages for item in page] + def api_one(path): + result = subprocess.run( + ["gh", "api", path], + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + repo = os.environ["REPOSITORY"] number = os.environ["PR_NUMBER"] head = os.environ["HEAD_SHA"] + base = os.environ["BASE_SHA"] + head_ref = os.environ["HEAD_REF"] + head_repo = os.environ["HEAD_REPOSITORY"] + author = os.environ["PR_AUTHOR"] reviewer = os.environ["PROTECTED_REVIEWER"].lower() manifest = json.loads(pathlib.Path("autorelease/protected-paths.json").read_text()) patterns = manifest["patterns"] @@ -87,6 +117,41 @@ jobs: print("No protected control path changed.") raise SystemExit(0) + readiness_run = re.fullmatch(r"autorelease/readiness-(\d+)", head_ref) + if ( + len(files) == 1 + and len(protected) == 1 + and re.fullmatch(r"readiness/[A-Za-z0-9._-]+\.json", protected[0]) + and readiness_run + and author == "github-actions[bot]" + 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 record["misePhpCommit"] == base + and run.get("path") == ".github/workflows/autorelease-consumer.yml" + and run.get("event") in {"schedule", "workflow_dispatch"} + and run.get("head_branch") == "main" + and run.get("status") == "in_progress" + ) + if direct_parent and trusted_run: + print(f"Protected readiness record approved from trusted consumer run {run['id']}.") + raise SystemExit(0) + print("Readiness record did not come from a trusted in-progress consumer run.", file=sys.stderr) + raise SystemExit(1) + reviews = api(f"repos/{repo}/pulls/{number}/reviews") approved = any( review.get("state") == "APPROVED" diff --git a/AUTORELEASE.md b/AUTORELEASE.md index aa699dc..f7ce5b3 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -8,10 +8,24 @@ The scheduled `php-bin policy consumer` captures the accepted public `support-policy.json` and compares it with `support-snapshot.json`: the policy digest, the invariants digest, the php-bin policy commit, the maintained branches, and any locally incomplete event. It does not fetch or classify -upstream PHP lifecycle data. When the exact policy changes, the -repository-scoped pinned Codex Action produces an evidence-bound plan. Any -implementation runs offline, without a GitHub write credential, and only -against admitted paths. +upstream PHP lifecycle data. The run stops before that capture unless every +path in `autorelease/shared-files.json` is byte-identical with `php-bin` at the +exact commit the operator control was read from. When the exact policy +changes, the repository-scoped pinned Codex Action produces an evidence-bound +plan. Any implementation runs offline, without a GitHub write credential, and +only against admitted paths. + +Which paths those are is the point. The *harness* is protected and never +model-editable: `scripts/test.sh`, `scripts/consume-php-policy`, +`scripts/generate-policy-lua`, `scripts/check-public-language.sh`, the sealing +and admission scripts, `test/`, `autorelease/`, `schemas/`, and +`.github/workflows/`. The *product* stays admissible: `hooks/*.lua`, `lib/`, +`metadata.lua`, and the generated `support-snapshot.json`. A model may change +what the plugin does, never what decides whether it still works, so the +protected plugin-contract tests are the standing control on every product +change. `autorelease-consumer.yml` runs `./scripts/test.sh` from the sealed +model commit for exactly that reason: the gates cannot have been part of the +patch, because admission rejects a protected path before sealing. ```mermaid flowchart TD @@ -34,6 +48,12 @@ waits for matching `php_bin_ready` and `mise_ready` records at exact commits. Failures and lifecycle transitions use one deduplicated GitHub issue per action key, assigned through `AUTORELEASE_OWNER`. Comments are added only for meaningful changes, and GitHub Actions failure email remains an independent fallback. +That issue is raised and updated by `php-bin`, which owns +`scripts/notify-autorelease` and the jobs holding `issues: write`. This +repository has no notification script and requests no issue permission at all, +so a failure confined to the consumer workflow reaches the owner through the +GitHub Actions failure email alone, until `php-bin` records it against the +action key. ```mermaid flowchart TD @@ -47,6 +67,25 @@ flowchart TD stop --> actions["Actions failure email"] ``` +## Unattended lifecycle + +Tracking a new PHP branch takes zero human input here. No matcher in this +plugin is anchored to a major or minor version, so `8.6`, `9.0`, and `10.0` +need no code change. When the accepted `php-bin` policy adds a branch, the +admitted patch regenerates `support-snapshot.json` and `lib/policy.lua` from +it, the plugin contract tests run against the sealed commit, and the exact +`mise_ready` record commits under `readiness/`. That record merges without a +reviewer because `readiness/` and `autorelease-events/` sit outside CODEOWNERS +by design, while every protected control still cannot merge that way. +`php-bin` publishes the new branch only once its own `php_bin_ready` and this +`mise_ready` record agree at exact commits. + +End of life is the same path in reverse and equally unattended. The branch +leaves the maintained set, so it stops appearing in `mise ls-remote` and stops +resolving from a shorthand such as `php@8.2`. Nothing is removed: an exact +published version such as `8.2.32` still installs, because its `php-bin` +release and checksum assets are immutable. + Pause unattended mutation in the reviewed `php-bin/.github/autorelease-operator.json` control. Read-only capture and investigation remain available while paused. Resume through a reviewed change; @@ -70,7 +109,7 @@ invocation, exact CLI version, and canonical `config.toml` loading against the reviewed offline contract in `.github/codex-action-contract.json` before exercising autorelease behavior. -Inspect `support-snapshot.json`, `autorelease-events/`, `readiness/`, retained +Inspect `support-snapshot.json`, `readiness/`, retained workflow artifacts, and the event's GitHub issue. Recovery corrects the cause and reruns the normal admitted path; it never disables checksum, policy, sealing, exact-SHA, or publication gates. diff --git a/README.md b/README.md index 0133634..eb1303a 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,20 @@ It never compiles PHP locally. ## Status The plugin contract, offline end-to-end tests, and installation from published -`php-bin` releases are verified on macOS 26 arm64. Maintained PHP releases for -8.2 through 8.5 are available now. +`php-bin` releases are verified on macOS 26 arm64. Run `mise ls-remote php` for +the versions available right now. ## Requirements - macOS 26 (Tahoe) or newer on arm64 / aarch64 - a current mise release with vfox tool-plugin support -The plugin supports the maintained PHP branches 8.2 through 8.5. PHP branches -that have reached end of life are intentionally not listed or installable. +The plugin supports the maintained PHP branches recorded in +[`support-snapshot.json`](support-snapshot.json), which tracks the accepted +`php-bin` support policy automatically. Branches that have reached end of life +are delisted, so they stop appearing in `mise ls-remote php` and stop resolving +from a branch shorthand. Exact versions published before that point remain +installable, because their `php-bin` releases are immutable. Other operating systems and Intel Macs receive an explicit unsupported-target error. Older macOS releases cannot load the published binaries. diff --git a/autorelease-events/.gitkeep b/autorelease-events/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/autorelease-events/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/autorelease/admission.py b/autorelease/admission.py old mode 100755 new mode 100644 index cd40144..7a62804 --- a/autorelease/admission.py +++ b/autorelease/admission.py @@ -1,5 +1,9 @@ -#!/usr/bin/env python3 -"""Deterministic admission and sealing for repository-scoped mise changes.""" +"""Deterministic admission and sealing for repository-scoped mise changes. + +This module imports from `autorelease.consumer`, so it is reached only as a package: +`scripts/admit-autorelease-plan`, `scripts/seal-autorelease-patch` and +`scripts/verify-merge-admission` are its command-line entry points. +""" from __future__ import annotations @@ -13,6 +17,10 @@ import sys from typing import Any +# The admissible action-key alphabet is defined once, beside the filename mapping that +# both repositories derive record and branch names from. +from autorelease.consumer import ACTION_KEY_RE + PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") try: @@ -20,14 +28,19 @@ except (OSError, KeyError, TypeError, json.JSONDecodeError) as error: raise RuntimeError(f"cannot load protected paths: {error}") from error PROHIBITED = {"merge", "push", "tag", "release", "publish", "workflow_permissions", "secret_access"} -ACTION_KEY_RE = re.compile( - r"^(new_patch:\d+\.\d+\.\d+|new_branch:\d+\.\d+|" - r"branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2}|" - r"recipe_rebuild:\d+\.\d+\.\d+:[1-9]\d*|" - r"repair:\d+\.\d+\.\d+:[0-9a-f]{8,64}|" - r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" +SECRET_RE = re.compile( + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----" + r"|github_pat_[A-Za-z0-9_]{20,}" + r"|\bgh[opusr]_[A-Za-z0-9]{30,}\b" + r"|\bsk-[A-Za-z0-9_-]{20,}\b" ) SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +REQUIRED_PLAN_CHECKS = ["Plugin contract"] +READINESS_RECORD_KEYS = { + "schemaVersion", "actionKey", "state", "ready", "phpBinPolicyCommit", + "policyDigest", "policyInvariantsDigest", "misePhpCommit", + "evidenceDigests", "recordedAt", +} class AdmissionError(RuntimeError): @@ -72,7 +85,39 @@ def contained_path(root: pathlib.Path, value: Any, label: str) -> pathlib.Path: def protected(path: str) -> bool: - return any(fnmatch.fnmatch(path, pattern) for pattern in PROTECTED) + """Match a repository path against the protected patterns. + + fnmatchcase, not fnmatch: fnmatch runs os.path.normcase first, which makes the + answer depend on the host platform. Git paths are case-sensitive bytes and this + gate decides admission, so the comparison has to be the same everywhere. + """ + return any(fnmatch.fnmatchcase(path, pattern) for pattern in PROTECTED) + + +def validate_readiness_record(record: Any) -> None: + """Exact-shape check for records produced by consumer.readiness().""" + if not isinstance(record, dict) or set(record) != READINESS_RECORD_KEYS: + raise AdmissionError("readiness record has unexpected shape") + if record["schemaVersion"] != 1 or record["state"] != "mise_ready" or record["ready"] is not True: + raise AdmissionError("readiness record has invalid state") + if not ACTION_KEY_RE.fullmatch(str(record["actionKey"])): + raise AdmissionError("readiness record has invalid action key") + for key in ("phpBinPolicyCommit", "misePhpCommit"): + if not re.fullmatch(r"[0-9a-f]{40}", str(record[key])): + raise AdmissionError(f"readiness record {key} is not an exact SHA") + for key in ("policyDigest", "policyInvariantsDigest"): + if not SHA256_RE.fullmatch(str(record[key])): + raise AdmissionError(f"readiness record {key} is not a digest") + digests = record["evidenceDigests"] + if ( + not isinstance(digests, list) + or not digests + or digests != sorted(digests) + or not all(isinstance(item, str) and SHA256_RE.fullmatch(item) for item in digests) + ): + raise AdmissionError("readiness record evidence digests are invalid") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", str(record["recordedAt"])): + raise AdmissionError("readiness record timestamp is invalid") def validate_assessment(assessment: dict, contract: dict, digests: dict) -> None: @@ -232,7 +277,7 @@ def admit( raise AdmissionError("plan repository authority is invalid") if plan.get("editsRequired") is not True: raise AdmissionError("changed accepted policy requires a synchronized snapshot edit") - if plan.get("requiredChecks") != ["Plugin contract"]: + if plan.get("requiredChecks") != REQUIRED_PLAN_CHECKS: raise AdmissionError("required deterministic checks changed") if plan.get("risk") not in {"routine", "compatibility", "lifecycle", "recovery", "policy-sensitive"}: raise AdmissionError("invalid plan risk") @@ -252,8 +297,12 @@ def admit( if protected(pattern): raise AdmissionError(f"runtime plan admits protected path: {pattern}") flattened.append(pattern) - if not any(fnmatch.fnmatch("support-snapshot.json", pattern) for pattern in flattened): + if not any(fnmatch.fnmatchcase("support-snapshot.json", pattern) for pattern in flattened): raise AdmissionError("policy synchronization does not admit the generated support snapshot") + # Sealing rejects a snapshot edit whose lib/policy.lua was not regenerated, so a + # plan that cannot carry the regenerated file is unsatisfiable rather than risky. + if not any(fnmatch.fnmatchcase("lib/policy.lua", pattern) for pattern in flattened): + raise AdmissionError("policy synchronization does not admit the generated lib/policy.lua") operations = plan.get("agentOperations") if not isinstance(operations, list) or not all(isinstance(item, str) for item in operations): raise AdmissionError("agent operations must be an array of strings") @@ -319,7 +368,7 @@ def seal( files = [] for path in paths: candidate = repo / path - if protected(path) or not any(fnmatch.fnmatch(path, pattern) for pattern in allowed): + if protected(path) or not any(fnmatch.fnmatchcase(path, pattern) for pattern in allowed): raise AdmissionError(f"forbidden diff path: {path}") if candidate.is_symlink() or not candidate.is_file() or candidate.stat().st_size > 2_000_000: raise AdmissionError(f"unsupported diff entry: {path}") @@ -333,7 +382,7 @@ def seal( text = body.decode("utf-8") except UnicodeDecodeError as error: raise AdmissionError(f"diff entry is not valid UTF-8: {path}") from error - if re.search(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|github_pat_|\\bsk-[A-Za-z0-9_-]{20,}", text): + if SECRET_RE.search(text): raise AdmissionError(f"secret-like material in diff: {path}") if path == "support-snapshot.json": try: @@ -365,6 +414,22 @@ def seal( } or snapshot.get("schemaVersion") != 1 or snapshot.get("generated") is not True: raise AdmissionError("support snapshot has unknown, missing, or invalid fields") files.append({"path": path, "digest": digest_bytes(body), "mode": oct(mode)}) + # The plugin filters branches through the generated lib/policy.lua, so either file + # changing alone would ship a filter that disagrees with the accepted snapshot. + if "support-snapshot.json" in paths or "lib/policy.lua" in paths: + maintained = load(repo / "support-snapshot.json").get("maintainedBranches", []) + expected_policy_lines = [ + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.", + "-- Do not edit by hand; regenerate when the snapshot changes.", + "return {", + " maintained = {", + *[f' "{branch}",' for branch in maintained], + " },", + "}", + ] + policy_lua = repo / "lib" / "policy.lua" + if not policy_lua.is_file() or policy_lua.read_text().splitlines() != expected_policy_lines: + raise AdmissionError("support snapshot changed without regenerating lib/policy.lua") output.mkdir(parents=True, exist_ok=True) patch = output / "sealed.patch" tracked_patch = subprocess.run( @@ -497,7 +562,3 @@ def main() -> int: except (AdmissionError, OSError, subprocess.CalledProcessError) as error: print(f"mise autorelease admission rejected: {error}", file=sys.stderr) return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/autorelease/consumer.py b/autorelease/consumer.py index ac335ce..5a50f45 100755 --- a/autorelease/consumer.py +++ b/autorelease/consumer.py @@ -51,6 +51,22 @@ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, return super().redirect_request(req, fp, code, msg, headers, newurl) +ACTION_FILENAME_MAP = str.maketrans({":": "-", "/": "-"}) + + +def action_filename(action_key: str, suffix: str = ".json") -> str: + """Return the single file or branch name an action key may occupy. + + php-bin names event records from an action key with exactly this mapping, and the + readiness record it reads back is matched by name, so the two repositories share one + definition of it. The key is model-authored and reaches shell arguments and + repository paths, so its alphabet is re-asserted at this boundary. + """ + if not ACTION_KEY_RE.fullmatch(action_key): + raise ConsumerError(f"invalid action key: {action_key}") + return action_key.translate(ACTION_FILENAME_MAP) + suffix + + def now() -> str: return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -183,7 +199,6 @@ def compare( invariants: pathlib.Path, policy_commit: pathlib.Path, snapshot: pathlib.Path, - events: pathlib.Path, ) -> dict[str, Any]: policy_digest = digest(policy.read_bytes()) policy_document = load(policy) @@ -266,17 +281,7 @@ def compare( or existing.get("generated") is not True ): raise ConsumerError("local support snapshot has unknown, missing, or invalid fields") - incomplete = [] - if events.exists(): - for path in events.glob("*.json"): - event = load(path) - if event.get("state") not in {"mise_ready", "complete"}: - incomplete.append(event.get("actionKey")) - if len(incomplete) > 1 or any(not ACTION_KEY_RE.fullmatch(value or "") for value in incomplete): - raise ConsumerError("local event state is ambiguous or invalid") - if incomplete: - trigger = "event_incomplete" - elif ( + if ( existing.get("policyDigest") != policy_digest or existing.get("policyInvariantsDigest") != invariants_digest or existing.get("phpBinPolicyCommit") != commit_sha @@ -288,11 +293,10 @@ def compare( return { "schemaVersion": 1, "trigger": trigger, - "actionKey": incomplete[0] if incomplete else policy_document.get("actionKey"), + "actionKey": policy_document.get("actionKey"), "policyDigest": policy_digest, "policyInvariantsDigest": invariants_digest, "phpBinPolicyCommit": commit_sha, - "incompleteActions": sorted(incomplete), "modelCall": trigger != "quiet", } @@ -348,7 +352,6 @@ def main() -> int: compare_parser.add_argument("--invariants", required=True, type=pathlib.Path) compare_parser.add_argument("--policy-commit", required=True, type=pathlib.Path) compare_parser.add_argument("--snapshot", required=True, type=pathlib.Path) - compare_parser.add_argument("--events", required=True, type=pathlib.Path) compare_parser.add_argument("--output", required=True, type=pathlib.Path) ready = sub.add_parser("readiness") ready.add_argument("--action-key", required=True) @@ -358,6 +361,9 @@ def main() -> int: ready.add_argument("--mise-commit", required=True) ready.add_argument("--evidence-digest", action="append", required=True) ready.add_argument("--output", required=True, type=pathlib.Path) + filename = sub.add_parser("action-filename") + filename.add_argument("action_key") + filename.add_argument("--suffix", default=".json") args = parser.parse_args() try: if args.command == "fetch": @@ -368,8 +374,10 @@ def main() -> int: "captures": fetch_policy_set(args.output, args.invariants_output, args.commit_output), }, ) + elif args.command == "action-filename": + print(action_filename(args.action_key, args.suffix)) elif args.command == "compare": - result = compare(args.policy, args.invariants, args.policy_commit, args.snapshot, args.events) + result = compare(args.policy, args.invariants, args.policy_commit, args.snapshot) write(args.output, result) print(json.dumps(result)) else: diff --git a/autorelease/protected-paths.json b/autorelease/protected-paths.json index dfa8537..98aedcc 100644 --- a/autorelease/protected-paths.json +++ b/autorelease/protected-paths.json @@ -3,18 +3,26 @@ "patterns": [ ".github/codex/autorelease/*", ".github/codex-action-contract.json", + ".github/dependabot.yml", ".github/workflows/*", ".codex/*", "schemas/*", "autorelease/*", "scripts/admit-autorelease-plan", + "scripts/assert-admission-checks", "scripts/dispatch-pr-checks", + "scripts/prepare-agent-task", "scripts/seal-autorelease-patch", "scripts/validate-codex-action-inputs", "scripts/validate-structured-output-schemas", "scripts/verify-merge-admission", "autorelease-events/*", "readiness/*", - ".github/CODEOWNERS" + ".github/CODEOWNERS", + "scripts/test.sh", + "scripts/check-public-language.sh", + "scripts/consume-php-policy", + "scripts/generate-policy-lua", + "test/*" ] } diff --git a/autorelease/shared-files.json b/autorelease/shared-files.json new file mode 100644 index 0000000..fb5ba78 --- /dev/null +++ b/autorelease/shared-files.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "paths": [ + ".codex/implementation.config.toml", + ".codex/repair.config.toml", + ".github/dependabot.yml", + "scripts/assert-admission-checks", + "scripts/check-public-language.sh", + "scripts/dispatch-pr-checks", + "scripts/validate-codex-action-inputs" + ] +} diff --git a/docs/repository-settings.md b/docs/repository-settings.md index cea7360..66dbb11 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -46,7 +46,7 @@ still rejects event/readiness paths as agent-authored changes. ```bash ./php-bin/scripts/snapshot-github-admin-state \ --repo bigpixelrocket/mise-php \ - --output mise-php/docs/admin-state/mise-php.json + --output mise-php/docs/admin-state/mise-php-after.json ./php-bin/scripts/configure-github-autorelease \ --repo bigpixelrocket/mise-php \ diff --git a/hooks/parse_legacy_file.lua b/hooks/parse_legacy_file.lua index 8563f3d..9d0fe4e 100644 --- a/hooks/parse_legacy_file.lua +++ b/hooks/parse_legacy_file.lua @@ -6,6 +6,6 @@ function PLUGIN:ParseLegacyFile(ctx) error("failed to read " .. ctx.filepath) end - local version = content:match("(8%.[2-5][^%s]*)") + local version = content:match("(%d+%.%d+[^%s]*)") return { version = version } end diff --git a/lib/policy.lua b/lib/policy.lua new file mode 100644 index 0000000..09180ca --- /dev/null +++ b/lib/policy.lua @@ -0,0 +1,10 @@ +-- Generated by scripts/generate-policy-lua from support-snapshot.json. +-- Do not edit by hand; regenerate when the snapshot changes. +return { + maintained = { + "8.2", + "8.3", + "8.4", + "8.5", + }, +} diff --git a/lib/releases.lua b/lib/releases.lua index a5f3ba5..4da2605 100644 --- a/lib/releases.lua +++ b/lib/releases.lua @@ -1,5 +1,6 @@ local http = require("http") local json = require("json") +local policy = require("policy") local M = {} @@ -47,8 +48,16 @@ end function M.is_supported_version(version) - return version:match("^8%.[2-5]%.%d+$") ~= nil - or version:match("^8%.[2-5]%.%d+%-[1-9]%d*$") ~= nil + for _, branch in ipairs(policy.maintained) do + local prefix = "^" .. branch:gsub("%.", "%%.") .. "%.%d+" + if version:match(prefix .. "$") ~= nil + or version:match(prefix .. "%-[1-9]%d*$") ~= nil + then + return true + end + end + + return false end diff --git a/schemas/autorelease-plan.schema.json b/schemas/autorelease-plan.schema.json index ceb0514..417b2cf 100644 --- a/schemas/autorelease-plan.schema.json +++ b/schemas/autorelease-plan.schema.json @@ -2,10 +2,13 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "actionKey", "action", "agentContract", "evidence", "repositories", "preconditions", "editsRequired", "allowedPaths", "requiredChecks", "agentOperations", "budgets", "notification", "risk", "completionAssessment", "summary"], + "required": ["schemaVersion", "actionKey", "action", "agentContract", "evidence", "repositories", "preconditions", "editsRequired", "allowedPaths", "requiredChecks", "agentOperations", "budgets", "risk", "completionAssessment", "summary"], "properties": { "schemaVersion": {"type": "integer", "const": 1}, - "actionKey": {"type": "string"}, + "actionKey": { + "type": "string", + "pattern": "^(new_patch:\\d+\\.\\d+\\.\\d+|new_branch:\\d+\\.\\d+|branch_eol:\\d+\\.\\d+:\\d{4}-\\d{2}-\\d{2}|recipe_rebuild:\\d+\\.\\d+\\.\\d+:[1-9]\\d*|repair:\\d+\\.\\d+\\.\\d+:[0-9a-f]{8,64}|(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" + }, "action": {"type": "string", "enum": ["no_change", "new_patch", "new_branch", "branch_eol", "repair", "reconcile_partial", "blocked", "needs_human"]}, "agentContract": { "type": "object", @@ -72,7 +75,7 @@ "mise-php": {"type": "array", "items": {"type": "string"}} } }, - "requiredChecks": {"type": "array", "items": {"type": "string"}, "const": ["Plugin contract"]}, + "requiredChecks": {"type": "array", "items": {"type": "string", "enum": ["Plugin contract"]}, "minItems": 1, "maxItems": 1}, "agentOperations": {"type": "array", "items": {"type": "string"}}, "budgets": { "type": "object", @@ -84,16 +87,6 @@ "timeoutMinutes": {"type": "integer", "minimum": 1, "maximum": 60} } }, - "notification": { - "type": "object", - "additionalProperties": false, - "required": ["suggestedSeverity", "summary", "humanActionRequired"], - "properties": { - "suggestedSeverity": {"type": "string", "enum": ["info", "warning", "critical"]}, - "summary": {"type": "string"}, - "humanActionRequired": {"type": "boolean"} - } - }, "risk": {"type": "string", "enum": ["routine", "compatibility", "lifecycle", "recovery", "policy-sensitive"]}, "completionAssessment": { "type": "object", diff --git a/scripts/assert-admission-checks b/scripts/assert-admission-checks new file mode 100755 index 0000000..9c54168 --- /dev/null +++ b/scripts/assert-admission-checks @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# 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" +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/scripts/check-public-language.sh b/scripts/check-public-language.sh index 494630e..b7601d2 100755 --- a/scripts/check-public-language.sh +++ b/scripts/check-public-language.sh @@ -6,17 +6,34 @@ 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. +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 -echo "Public-language check passed." +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" && { xargs -0 grep -HIFni -e "$REJECTED_TERM" < "$tracked" || true; })" +if [[ -n "$matches" ]]; then + printf '%s\n' "$matches" >&2 + echo "Public-language check failed." >&2 + exit 1 +fi + +echo "Public-language check passed." diff --git a/scripts/generate-policy-lua b/scripts/generate-policy-lua new file mode 100755 index 0000000..ef8f919 --- /dev/null +++ b/scripts/generate-policy-lua @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Regenerates lib/policy.lua from support-snapshot.json so the Lua plugin +# lists exactly the maintained branches without hardcoding them. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +{ + echo "-- Generated by scripts/generate-policy-lua from support-snapshot.json." + echo "-- Do not edit by hand; regenerate when the snapshot changes." + echo "return {" + echo " maintained = {" + jq -r '.maintainedBranches[] | " \"\(.)\","' support-snapshot.json + echo " }," + echo "}" +} > lib/policy.lua diff --git a/scripts/prepare-agent-task b/scripts/prepare-agent-task new file mode 100755 index 0000000..5c53ef9 --- /dev/null +++ b/scripts/prepare-agent-task @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Emit the reviewed completion criteria for one agent phase.""" + +import argparse +import json + + +CRITERIA = { + "investigation": [ + { + "id": "phase-goal-correct", + "requirement": "The goal matches exact inputs.", + "evidenceRequired": "Exact preconditions.", + }, + { + "id": "policy-difference-explained", + "requirement": "Every required local change is bound to captured policy.", + "evidenceRequired": "Policy digest and JSON locator.", + }, + { + "id": "authority-explicit", + "requirement": "Paths and checks are explicit.", + "evidenceRequired": "Allowed paths and required checks.", + }, + { + "id": "no-unresolved-work", + "requirement": "No contradiction or stop condition remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], + "implementation": [ + { + "id": "phase-goal-correct", + "requirement": "Goal and preconditions remain exact.", + "evidenceRequired": "Plan digest and base SHA.", + }, + { + "id": "admitted-diff-complete", + "requirement": "Diff is complete and admitted.", + "evidenceRequired": "Final diff and path comparison.", + }, + { + "id": "advisory-checks-recorded", + "requirement": "All checks ran.", + "evidenceRequired": "Commands and results.", + }, + { + "id": "no-unresolved-work", + "requirement": "No in-scope work remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], + "repair": [ + { + "id": "phase-goal-correct", + "requirement": "The repair goal matches the exact failure and inputs.", + "evidenceRequired": "Failure fingerprint and exact base.", + }, + { + "id": "failure-cause-removed", + "requirement": "The named failure is removed without weakening a gate.", + "evidenceRequired": "Minimal admitted diff and reproduction evidence.", + }, + { + "id": "advisory-checks-recorded", + "requirement": "All advisory checks ran.", + "evidenceRequired": "Commands and results.", + }, + { + "id": "no-unresolved-work", + "requirement": "No repeated or unrelated failure remains.", + "evidenceRequired": "Empty unresolved list.", + }, + ], +} + + +parser = argparse.ArgumentParser() +parser.add_argument("--phase", choices=sorted(CRITERIA), required=True) +args = parser.parse_args() + +# Key order is contractual: the workflow injects this array verbatim, so the emitted +# contract must stay byte-identical to the jq literals this table replaced. +print(json.dumps(CRITERIA[args.phase])) diff --git a/scripts/test.sh b/scripts/test.sh index d2a62af..e16f065 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,6 +8,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" "$SCRIPT_DIR/check-public-language.sh" "$SCRIPT_DIR/validate-codex-action-inputs" "$SCRIPT_DIR/validate-structured-output-schemas" +"$SCRIPT_DIR/generate-policy-lua" +git -C "$PROJECT_ROOT" diff --exit-code lib/policy.lua if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then echo "Plugin installation tests require macOS arm64." >&2 @@ -21,7 +23,11 @@ fi TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mise-php-test.XXXXXX")" SERVER_PID="" +ORIGINAL_POLICY="" cleanup() { + if [[ -n "$ORIGINAL_POLICY" ]]; then + printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua" + fi if [[ -n "$SERVER_PID" ]]; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true @@ -44,9 +50,11 @@ ARCHIVE_NAME="php-8.4.99-cli-macos-aarch64.tar.gz" EOL_ARCHIVE_NAME="php-8.1.99-cli-macos-aarch64.tar.gz" COPYFILE_DISABLE=1 tar -czf "$TEMP_DIR/assets/$ARCHIVE_NAME" -C "$TEMP_DIR/assets/package" . cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$EOL_ARCHIVE_NAME" +FUTURE_ARCHIVE_NAME="php-9.0.1-cli-macos-aarch64.tar.gz" +cp "$TEMP_DIR/assets/$ARCHIVE_NAME" "$TEMP_DIR/assets/$FUTURE_ARCHIVE_NAME" ( cd "$TEMP_DIR/assets" - shasum -a 256 "$ARCHIVE_NAME" "$EOL_ARCHIVE_NAME" > SHA256SUMS + shasum -a 256 "$ARCHIVE_NAME" "$EOL_ARCHIVE_NAME" "$FUTURE_ARCHIVE_NAME" > SHA256SUMS ) PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" @@ -75,6 +83,19 @@ if grep -Fx "8.1.99" <<< "$AVAILABLE_VERSIONS"; then echo "EOL PHP release was unexpectedly listed." >&2 exit 1 fi + +# A future branch appears in listings the moment the snapshot maintains it. +if grep -Fx "9.0.1" <<< "$AVAILABLE_VERSIONS"; then + echo "Unmaintained future branch was unexpectedly listed." >&2 + exit 1 +fi +# cleanup restores lib/policy.lua, so a failure mid-swap cannot leave it mutated. +ORIGINAL_POLICY="$(cat "$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)" +printf '%s\n' "$ORIGINAL_POLICY" > "$PROJECT_ROOT/lib/policy.lua" +grep -Fx "9.0.1" <<< "$FUTURE_VERSIONS" + mise install php@8.4 test -x "$MISE_DATA_DIR/installs/php/8.4.99/bin/php" mise exec php@8.4 -- php -v | grep -F "PHP 8.4.99" diff --git a/scripts/validate-codex-action-inputs b/scripts/validate-codex-action-inputs index 9ce931e..9cb9834 100755 --- a/scripts/validate-codex-action-inputs +++ b/scripts/validate-codex-action-inputs @@ -15,6 +15,10 @@ from typing import Any ROOT = pathlib.Path(__file__).resolve().parents[1] CONTRACT_PATH = ROOT / ".github/codex-action-contract.json" BOT_USER_RE = re.compile(r"^[A-Za-z0-9-]+(?:\[bot\])?$") +CANONICAL_CONFIG_RE = re.compile( + r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/config\.toml"' +) +UNLOADED_CONFIG_RE = re.compile(r'cp\s+"?\.codex/\S+\.config\.toml"?\s+"\$RUNNER_TEMP/codex-home/"') def load_workflow(path: pathlib.Path) -> dict[str, Any]: @@ -54,12 +58,7 @@ def validate() -> dict[str, Any]: required_bot_users = set(contract["securityInputs"]["allow-bot-users"]["requiredValues"]) expected_ref = f"{action}@{commit}" invocations = [] - canonical_config_copies = 0 for path in sorted((ROOT / ".github/workflows").glob("*.yml")): - workflow_body = path.read_text() - canonical_config_copies += workflow_body.count('"$RUNNER_TEMP/codex-home/config.toml"') - if re.search(r'cp\s+\.codex/\S+\.config\.toml\s+"\$RUNNER_TEMP/codex-home/"', workflow_body): - raise ValueError(f"{path.relative_to(ROOT)} copies a phase config under an unloaded filename") document = load_workflow(path) jobs = document.get("jobs", {}) if not isinstance(jobs, dict): @@ -67,9 +66,10 @@ def validate() -> dict[str, Any]: for job_name, job in jobs.items(): if not isinstance(job, dict): continue - for index, step in enumerate(job.get("steps", [])): - if not isinstance(step, dict): - continue + steps = [step for step in job.get("steps", []) if isinstance(step, dict)] + for index, step in enumerate(steps): + if UNLOADED_CONFIG_RE.search(step.get("run") or ""): + raise ValueError(f"{path.relative_to(ROOT)} copies a phase config under an unloaded filename") uses = step.get("uses") if not isinstance(uses, str) or not uses.startswith(f"{action}@"): continue @@ -104,15 +104,15 @@ def validate() -> dict[str, Any]: bot_users = {item.strip() for item in allow_bot_users.split(",") if item.strip()} if bot_users != required_bot_users or not all(BOT_USER_RE.fullmatch(item) for item in bot_users): raise ValueError(f"{location} allow-bot-users does not match the reviewed bot allowlist") + # Only a config at the canonical filename is loaded from the + # Codex home, so every invocation needs one earlier in its job. + if not any(CANONICAL_CONFIG_RE.search(earlier.get("run") or "") for earlier in steps[:index]): + raise ValueError(f"{location} starts without a canonical Codex config load in its job") invocations.append({"workflow": str(path.relative_to(ROOT)), "job": job_name, "step": index + 1}) expected_count = contract["expectedInvocations"] if len(invocations) != expected_count: raise ValueError(f"expected {expected_count} Codex Action invocations, found {len(invocations)}") - if canonical_config_copies != expected_count: - raise ValueError( - f"expected {expected_count} canonical Codex config copies, found {canonical_config_copies}" - ) return { "action": action, "commit": commit, diff --git a/scripts/validate-structured-output-schemas b/scripts/validate-structured-output-schemas index 565ad93..b4b5980 100755 --- a/scripts/validate-structured-output-schemas +++ b/scripts/validate-structured-output-schemas @@ -14,6 +14,12 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] OUTPUT_SCHEMA_RE = re.compile(r'--output-schema","([^"]+\.json)"') UNSUPPORTED_KEYWORDS = {"uniqueItems"} +sys.path.insert(0, str(ROOT)) +from autorelease.admission import ( # noqa: E402 + ACTION_KEY_RE, + REQUIRED_PLAN_CHECKS, +) + def fail(message: str) -> None: print(f"Structured output schema error: {message}", file=sys.stderr) @@ -44,6 +50,8 @@ def validate_node(node: Any, location: str) -> None: if ("const" in node or "enum" in node) and "type" not in node: fail(f"{location} uses const or enum without an explicit type") + if "const" in node and isinstance(node["const"], (dict, list)): + fail(f"{location} uses a non-scalar const unsupported by OpenAI Structured Outputs") unsupported = sorted(UNSUPPORTED_KEYWORDS.intersection(node)) if unsupported: @@ -70,6 +78,17 @@ def main() -> int: except json.JSONDecodeError as error: fail(f"{path.relative_to(ROOT)} is invalid JSON: {error}") validate_node(document, str(path.relative_to(ROOT))) + if path == ROOT / "schemas/autorelease-plan.schema.json": + properties = document.get("properties", {}) + if properties.get("actionKey", {}).get("pattern") != ACTION_KEY_RE.pattern: + fail("autorelease plan actionKey pattern must match deterministic admission") + required_checks = properties.get("requiredChecks", {}) + if ( + required_checks.get("items", {}).get("enum") != REQUIRED_PLAN_CHECKS + or required_checks.get("minItems") != len(REQUIRED_PLAN_CHECKS) + or required_checks.get("maxItems") != len(REQUIRED_PLAN_CHECKS) + ): + fail("autorelease plan requiredChecks must match deterministic admission") print(f"Validated {len(schema_paths)} Codex Structured Outputs schemas.") return 0 diff --git a/scripts/verify-merge-admission b/scripts/verify-merge-admission index 2a22dda..963be83 100755 --- a/scripts/verify-merge-admission +++ b/scripts/verify-merge-admission @@ -1,4 +1,9 @@ -#!/usr/bin/env bash -set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -exec python3 "$ROOT/autorelease/admission.py" verify-merge "$@" +#!/usr/bin/env python3 +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +from autorelease.admission import main + +sys.argv.insert(1, "verify-merge") +raise SystemExit(main()) diff --git a/test/mock_server.py b/test/mock_server.py index 9cde3b3..fb3cfbb 100755 --- a/test/mock_server.py +++ b/test/mock_server.py @@ -11,8 +11,10 @@ ASSET_DIR = Path(sys.argv[2]).resolve() VERSION = "8.4.99" EOL_VERSION = "8.1.99" +FUTURE_VERSION = "9.0.1" ARCHIVE_NAME = f"php-{VERSION}-cli-macos-aarch64.tar.gz" EOL_ARCHIVE_NAME = f"php-{EOL_VERSION}-cli-macos-aarch64.tar.gz" +FUTURE_ARCHIVE_NAME = f"php-{FUTURE_VERSION}-cli-macos-aarch64.tar.gz" def release_payload() -> dict: @@ -53,6 +55,25 @@ def eol_release_payload() -> dict: } +def future_release_payload() -> dict: + base_url = f"http://127.0.0.1:{PORT}/assets" + return { + "tag_name": FUTURE_VERSION, + "draft": False, + "prerelease": False, + "assets": [ + { + "name": FUTURE_ARCHIVE_NAME, + "browser_download_url": f"{base_url}/{FUTURE_ARCHIVE_NAME}", + }, + { + "name": "SHA256SUMS", + "browser_download_url": f"{base_url}/SHA256SUMS", + }, + ], + } + + class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: path = urlparse(self.path).path @@ -62,7 +83,9 @@ def do_GET(self) -> None: return if path == "/repos/bigpixelrocket/php-bin/releases": - self.send_json([release_payload(), eol_release_payload()]) + self.send_json( + [release_payload(), eol_release_payload(), future_release_payload()] + ) return if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{VERSION}": @@ -71,11 +94,14 @@ def do_GET(self) -> None: if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{EOL_VERSION}": self.send_json(eol_release_payload()) return + if path == f"/repos/bigpixelrocket/php-bin/releases/tags/{FUTURE_VERSION}": + self.send_json(future_release_payload()) + return asset_prefix = "/assets/" if path.startswith(asset_prefix): name = path[len(asset_prefix) :] - if name not in {ARCHIVE_NAME, EOL_ARCHIVE_NAME, "SHA256SUMS"}: + if name not in {ARCHIVE_NAME, EOL_ARCHIVE_NAME, FUTURE_ARCHIVE_NAME, "SHA256SUMS"}: self.send_error(404) return diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 8be362d..6feba0d 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -5,8 +5,8 @@ import json from unittest import mock -from autorelease import consumer -from autorelease.admission import AdmissionError, admit, digest_file, protected, verify_merge +from autorelease import admission, consumer +from autorelease.admission import AdmissionError, admit, digest_file, protected, seal, verify_merge from autorelease.consumer import ( CaptureAbsent, ConsumerError, @@ -27,8 +27,6 @@ def test_opaque_policy_comparison(self): invariants = root / "invariants.json" commit = root / "commit.json" snapshot = root / "snapshot.json" - events = root / "events" - events.mkdir() invariants.write_text('{"schemaVersion":1,"target":{"os":"macOS","minimumVersion":"26.0","architecture":"arm64","sapi":"cli"},"allowPrereleases":false,"historicalExactVersionsRemainInstallable":true,"immutablePublishedAssets":true}\n') policy.write_text(json.dumps({ "schemaVersion": 1, @@ -47,7 +45,7 @@ def test_opaque_policy_comparison(self): "maintainedBranches": ["8.5"], "generated": True, }) - result = compare(policy, invariants, commit, snapshot, events) + result = compare(policy, invariants, commit, snapshot) self.assertEqual("quiet", result["trigger"]) policy.write_text(json.dumps({ "schemaVersion": 1, @@ -57,7 +55,7 @@ def test_opaque_policy_comparison(self): "actionKey": "bootstrap", "acceptedAt": "2026-07-27T00:00:00Z", }) + "\n") - self.assertEqual("policy_changed", compare(policy, invariants, commit, snapshot, events)["trigger"]) + self.assertEqual("policy_changed", compare(policy, invariants, commit, snapshot)["trigger"]) def test_readiness_requires_exact_commits_and_digests(self): result = readiness( @@ -72,6 +70,61 @@ def test_readiness_requires_exact_commits_and_digests(self): with self.assertRaises(Exception): readiness("new_branch:8.6", "main", "bad", "bad", "main", []) + def test_validate_readiness_record_accepts_consumer_output(self): + record = consumer.readiness( + "new_patch:8.5.9", + "a" * 40, + "sha256:" + "b" * 64, + "sha256:" + "c" * 64, + "d" * 40, + ["sha256:" + "e" * 64], + ) + admission.validate_readiness_record(record) + + def test_validate_readiness_record_rejects_tampering(self): + record = consumer.readiness( + "new_patch:8.5.9", + "a" * 40, + "sha256:" + "b" * 64, + "sha256:" + "c" * 64, + "d" * 40, + ["sha256:" + "e" * 64], + ) + for corrupt in ( + {**record, "ready": False}, + {**record, "state": "published"}, + {**record, "actionKey": "merge:now"}, + {**record, "extra": 1}, + {k: v for k, v in record.items() if k != "evidenceDigests"}, + ): + with self.assertRaises(admission.AdmissionError): + admission.validate_readiness_record(corrupt) + + def test_action_key_alphabet_and_filename_have_one_definition(self): + # admission and consumer both name files and branches from an action key; a + # second copy of either rule drifts silently against php-bin. + # re.compile caches by pattern, so identical copies are indistinguishable at + # runtime; the single definition is only observable in the source. + source = pathlib.Path("autorelease/admission.py").read_text() + self.assertIn("from autorelease.consumer import ACTION_KEY_RE", source) + self.assertNotIn("ACTION_KEY_RE = re.compile", source) + self.assertEqual(admission.ACTION_KEY_RE.pattern, consumer.ACTION_KEY_RE.pattern) + self.assertEqual( + "branch_eol-8.2-2026-12-31.json", consumer.action_filename("branch_eol:8.2:2026-12-31") + ) + self.assertEqual("new_patch-8.5.9", consumer.action_filename("new_patch:8.5.9", "")) + with self.assertRaises(ConsumerError): + consumer.action_filename("../escape") + # The workflow reaches the helper through the same entry point as every other + # consumer subcommand, so the shell sites cannot re-derive the mapping. + result = subprocess.run( + ["./scripts/consume-php-policy", "action-filename", "new_patch:8.5.9"], + check=True, text=True, stdout=subprocess.PIPE, + ) + self.assertEqual("new_patch-8.5.9.json", result.stdout.strip()) + workflow = pathlib.Path(".github/workflows/autorelease-consumer.yml").read_text() + self.assertNotIn("tr ':/'", workflow) + def test_protected_controls_are_not_admissible(self): self.assertTrue(protected(".github/codex-action-contract.json")) self.assertTrue(protected(".github/workflows/autorelease-consumer.yml")) @@ -81,6 +134,61 @@ def test_protected_controls_are_not_admissible(self): self.assertTrue(protected("readiness/new-branch.json")) self.assertFalse(protected("lib/releases.lua")) + def test_gate_harness_paths_are_protected(self): + for path in ("scripts/test.sh", "scripts/check-public-language.sh", + "scripts/consume-php-policy", "scripts/generate-policy-lua", + "test/test_autorelease.py"): + self.assertTrue(protected(path), path) + # Runtime patches regenerate the policy table, so the generated file stays admissible. + self.assertFalse(protected("lib/policy.lua")) + + def test_codeowners_covers_every_protected_script(self): + patterns = json.loads(pathlib.Path("autorelease/protected-paths.json").read_text())["patterns"] + codeowners = pathlib.Path(".github/CODEOWNERS").read_text() + for pattern in patterns: + if "*" not in pattern: + self.assertIn(f"/{pattern} ", codeowners, pattern) + + def test_shared_file_manifest_gates_the_consumer_run(self): + # ~20 files are duplicated from php-bin and most had drifted silently. The + # manifest declares the intended-identical set; the consumer compares it + # against php-bin at the exact pinned commit before it mutates anything. + root = pathlib.Path(__file__).resolve().parents[1] + manifest = json.loads((root / "autorelease/shared-files.json").read_text()) + self.assertEqual(1, manifest["schemaVersion"]) + paths = manifest["paths"] + self.assertEqual(sorted(set(paths)), paths) + # An emptied manifest satisfies every shape assertion while gating nothing, + # so the scripts the gate exists for are named outright. + self.assertLessEqual( + { + "scripts/assert-admission-checks", + "scripts/check-public-language.sh", + "scripts/dispatch-pr-checks", + }, + set(paths), + ) + for path in paths: + self.assertTrue((root / path).is_file(), path) + # A shared file an agent may rewrite would fail the gate on the next + # run, so every listed path needs owner review of its own. + self.assertTrue(protected(path), path) + self.assertTrue(protected("autorelease/shared-files.json")) + consumer = (root / ".github/workflows/autorelease-consumer.yml").read_text() + self.assertIn("jq -r '.paths[]' autorelease/shared-files.json", consumer) + self.assertIn('if [[ "${#shared[@]}" -eq 0 ]]; then', consumer) + + def test_secret_scanner_catches_sk_tokens(self): + for secret in ( + "key = sk-" + "a" * 24, + "github_pat_" + "a" * 22, + "ghp_" + "a" * 36, + "-----BEGIN OPENSSH PRIVATE KEY-----", + ): + self.assertIsNotNone(admission.SECRET_RE.search(secret), secret) + for benign in ("task-" + "a" * 24, "github_pat_x", "flask-login"): + self.assertIsNone(admission.SECRET_RE.search(benign), benign) + def test_investigation_defers_required_checks_to_writable_jobs(self): root = pathlib.Path(__file__).resolve().parents[1] instructions = (root / ".github/codex/autorelease/investigation.md").read_text() @@ -94,6 +202,55 @@ def test_investigation_defers_required_checks_to_writable_jobs(self): consumer, ) + def test_agent_task_criteria_come_from_one_table(self): + # Criteria used to be authored as jq literals in three workflow steps, so the + # only way to check them was matching workflow source text. They now come from + # one script and the emitted JSON is what the agent actually receives. + script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/prepare-agent-task") + expected = { + "investigation": [ + "phase-goal-correct", + "policy-difference-explained", + "authority-explicit", + "no-unresolved-work", + ], + "implementation": [ + "phase-goal-correct", + "admitted-diff-complete", + "advisory-checks-recorded", + "no-unresolved-work", + ], + "repair": [ + "phase-goal-correct", + "failure-cause-removed", + "advisory-checks-recorded", + "no-unresolved-work", + ], + } + for phase, ids in expected.items(): + emitted = json.loads( + subprocess.run([script, "--phase", phase], capture_output=True, check=True).stdout + ) + self.assertEqual(ids, [criterion["id"] for criterion in emitted], phase) + for criterion in emitted: + self.assertEqual(["id", "requirement", "evidenceRequired"], list(criterion), phase) + self.assertTrue(all(criterion.values()), phase) + self.assertNotEqual(0, subprocess.run([script, "--phase", "audit"], capture_output=True).returncode) + + def test_assert_admission_checks_covers_the_plugin_contract_bucket(self): + # The consumer merge gates only ever pass --check-name, so this repository's + # copy of the shared script must keep that path working on its own. + script = str(pathlib.Path(__file__).resolve().parents[1] / "scripts/assert-admission-checks") + with tempfile.TemporaryDirectory() as temporary: + checks = pathlib.Path(temporary) / "checks.json" + checks.write_text(json.dumps([{"name": "Plugin contract", "bucket": "pass"}])) + subprocess.run([script, "--check-name", "Plugin contract", "--checks", str(checks)], check=True) + checks.write_text(json.dumps([{"name": "Script checks", "bucket": "pass"}])) + result = subprocess.run( + [script, "--check-name", "Plugin contract", "--checks", str(checks)], capture_output=True + ) + self.assertNotEqual(0, result.returncode) + def test_policy_capture_urls_are_commit_pinned(self): sha = "a" * 40 policy, invariants = pinned_policy_urls(sha) @@ -165,92 +322,271 @@ def test_merge_gate_binds_single_commit_diff_and_preconditions(self): with self.assertRaises(AdmissionError): verify_merge(root, mutated, manifest, {"Plugin contract": "success"}, state, state) - def test_admission_binds_complete_policy_capture_and_contract(self): + def test_merge_admission_cli_prints_the_verdict_and_fails_closed(self): + # The merge job reaches the gate through this entry point and reads nothing + # but its exit status, so a rejection that exits 0 would merge an unadmitted + # patch. The in-process test above covers what the gate decides. with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) - shared = root / "shared.md" - phase = root / "phase.md" - event = root / "event.json" - shared.write_text("shared\n") - phase.write_text("phase\n") - commit_sha = "a" * 40 - policy_digest = "sha256:" + "b" * 64 - invariants_digest = "sha256:" + "c" * 64 - preconditions = { - "misePhpHead": "d" * 40, - "phpBinPolicyCommit": commit_sha, - "supportPolicyDigest": policy_digest, - "policyInvariantsDigest": invariants_digest, - "phpBinOperatorCommit": "e" * 40, - "operatorState": "enabled", + for arguments in ( + ["init", "-q", "-b", "main"], + ["config", "user.name", "test"], + ["config", "user.email", "test@invalid"], + ): + subprocess.run(["git", *arguments], cwd=root, check=True) + (root / "file.txt").write_text("base\n") + subprocess.run(["git", "add", "file.txt"], cwd=root, check=True) + subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=root, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + (root / "file.txt").write_text("validated\n") + subprocess.run(["git", "add", "file.txt"], cwd=root, check=True) + subprocess.run(["git", "commit", "-q", "-m", "validated"], cwd=root, check=True) + head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + paths = { + "manifest": { + "baseSha": base, + "files": [{ + "path": "file.txt", + "digest": digest((root / "file.txt").read_bytes()), + "mode": "0o644", + }], + }, + "checks": {"Plugin contract": "success"}, + "preconditions": {"misePhpHead": base}, + "current": {"misePhpHead": base}, } - contract = { - "contractVersion": 1, + for name, body in paths.items(): + (root / f"{name}.json").write_text(json.dumps(body) + "\n") + + def run_gate(expected_head): + return subprocess.run( + [ + "./scripts/verify-merge-admission", + "--repo", str(root), + "--head", expected_head, + "--manifest", str(root / "manifest.json"), + "--checks", str(root / "checks.json"), + "--preconditions", str(root / "preconditions.json"), + "--current", str(root / "current.json"), + ], + check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + admitted = run_gate(head) + self.assertEqual(0, admitted.returncode, admitted.stderr) + self.assertTrue(json.loads(admitted.stdout)["admitted"]) + rejected = run_gate(base) + self.assertEqual(1, rejected.returncode) + self.assertIn("mise autorelease admission rejected", rejected.stderr) + + # Returns an admissible plan plus the remaining admit() arguments by keyword, so + # a test can vary one part of the plan without rebuilding the policy capture. + def admission_fixture(self, root): + shared = root / "shared.md" + phase = root / "phase.md" + event = root / "event.json" + shared.write_text("shared\n") + phase.write_text("phase\n") + commit_sha = "a" * 40 + policy_digest = "sha256:" + "b" * 64 + invariants_digest = "sha256:" + "c" * 64 + preconditions = { + "misePhpHead": "d" * 40, + "phpBinPolicyCommit": commit_sha, + "supportPolicyDigest": policy_digest, + "policyInvariantsDigest": invariants_digest, + "phpBinOperatorCommit": "e" * 40, + "operatorState": "enabled", + } + contract = { + "contractVersion": 1, + "actionKey": "new_branch:8.6", + "preconditions": preconditions, + "completionCriteria": [{"id": "done"}], + } + event.write_text(json.dumps(contract) + "\n") + captures = [ + ("php_bin_policy_selector", [{"sha": commit_sha}], "/0/sha"), + ("php_bin_state", {"sha": commit_sha}, "/sha"), + ("support_policy", {"maintainedBranches": ["8.6"]}, "/maintainedBranches"), + ("policy_invariants", {"target": {"os": "macOS"}}, "/target"), + ] + manifest_records = [] + evidence = [] + for capture_id, body, pointer in captures: + path = root / f"{capture_id}.json" + path.write_text(json.dumps(body) + "\n") + body_digest = digest(path.read_bytes()) + if capture_id == "support_policy": + policy_digest = body_digest + preconditions["supportPolicyDigest"] = body_digest + elif capture_id == "policy_invariants": + invariants_digest = body_digest + preconditions["policyInvariantsDigest"] = body_digest + manifest_records.append({"captureId": capture_id, "bodyPath": path.name, "digest": body_digest}) + evidence.append({"captureId": capture_id, "digest": body_digest, "locator": {"kind": "json_pointer", "value": pointer}}) + event.write_text(json.dumps(contract) + "\n") + manifest = root / "capture.json" + manifest.write_text(json.dumps({"schemaVersion": 1, "captures": manifest_records}) + "\n") + digests = { + "shared": digest_file(shared), + "phaseTemplate": digest_file(phase), + "eventContract": digest_file(event), + } + plan = { + "schemaVersion": 1, + "actionKey": "new_branch:8.6", + "action": "new_branch", + "agentContract": {"instructionDigests": digests}, + "completionAssessment": { + "instructionDigests": digests, + "phaseStatus": "complete", + "criteria": [{"id": "done", "status": "passed", "evidence": ["evidence[0]"]}], + "unresolved": [], + "goNoGo": "go", + }, + "preconditions": preconditions, + "evidence": evidence, + "repositories": ["mise-php"], + "editsRequired": True, + "allowedPaths": {"mise-php": ["support-snapshot.json", "lib/policy.lua"]}, + "requiredChecks": ["Plugin contract"], + "risk": "lifecycle", + "agentOperations": [], + "budgets": {"maxModelCalls": 1, "maxRetries": 1, "timeoutMinutes": 30}, + } + return plan, { + "contract": contract, + "shared": shared, + "phase": phase, + "event": event, + "capture_manifest": manifest, + "policy_digest": policy_digest, + "invariants_digest": invariants_digest, + "mise_head": preconditions["misePhpHead"], + } + + def test_admission_binds_complete_policy_capture_and_contract(self): + with tempfile.TemporaryDirectory() as temporary: + plan, arguments = self.admission_fixture(pathlib.Path(temporary)) + self.assertTrue(admit(plan, **arguments)["admitted"]) + with self.assertRaises(AdmissionError): + admit(plan, **{**arguments, "policy_digest": "sha256:" + "f" * 64}) + + def test_admission_requires_the_generated_policy_lua_path(self): + with tempfile.TemporaryDirectory() as temporary: + plan, arguments = self.admission_fixture(pathlib.Path(temporary)) + plan["allowedPaths"] = {"mise-php": ["support-snapshot.json"]} + with self.assertRaises(AdmissionError) as ctx: + admit(plan, **arguments) + self.assertIn("lib/policy.lua", str(ctx.exception)) + plan["allowedPaths"] = {"mise-php": ["support-snapshot.json", "lib/policy.lua"]} + self.assertTrue(admit(plan, **arguments)["admitted"]) + + def generated_policy_lua(self, branches): + return ( + "-- Generated by scripts/generate-policy-lua from support-snapshot.json.\n" + "-- Do not edit by hand; regenerate when the snapshot changes.\n" + "return {\n" + " maintained = {\n" + + "".join(f' "{branch}",\n' for branch in branches) + + " },\n" + "}\n" + ) + + # Commits a repo whose base snapshot and lib/policy.lua both list base_branches and + # returns the repo, its base commit, and the remaining seal() arguments by keyword. + def seal_fixture(self, root, accepted, base_branches): + repo = root / "repo" + (repo / "lib").mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "test@invalid"], cwd=repo, check=True) + policy = root / "support-policy.json" + policy.write_text(json.dumps({"maintainedBranches": accepted}) + "\n") + preconditions = { + "misePhpHead": "d" * 40, + "phpBinPolicyCommit": "a" * 40, + "supportPolicyDigest": digest_file(policy), + "policyInvariantsDigest": "sha256:" + "c" * 64, + "phpBinOperatorCommit": "e" * 40, + "operatorState": "enabled", + } + (repo / "support-snapshot.json").write_text(json.dumps({ + "schemaVersion": 1, + "phpBinPolicyCommit": preconditions["phpBinPolicyCommit"], + "policyDigest": preconditions["supportPolicyDigest"], + "policyInvariantsDigest": preconditions["policyInvariantsDigest"], + "maintainedBranches": base_branches, + "generated": True, + }) + "\n") + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(base_branches)) + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, stdout=subprocess.PIPE + ).stdout.strip() + digests = { + "shared": "sha256:" + "1" * 64, + "phaseTemplate": "sha256:" + "2" * 64, + "eventContract": "sha256:" + "3" * 64, + } + return repo, base, { + "plan": { "actionKey": "new_branch:8.6", + "agentContract": {"instructionDigests": digests}, "preconditions": preconditions, - "completionCriteria": [{"id": "done"}], - } - event.write_text(json.dumps(contract) + "\n") - captures = [ - ("php_bin_policy_selector", [{"sha": commit_sha}], "/0/sha"), - ("php_bin_state", {"sha": commit_sha}, "/sha"), - ("support_policy", {"maintainedBranches": ["8.6"]}, "/maintainedBranches"), - ("policy_invariants", {"target": {"os": "macOS"}}, "/target"), - ] - manifest_records = [] - evidence = [] - for capture_id, body, pointer in captures: - path = root / f"{capture_id}.json" - path.write_text(json.dumps(body) + "\n") - body_digest = digest(path.read_bytes()) - if capture_id == "support_policy": - policy_digest = body_digest - preconditions["supportPolicyDigest"] = body_digest - elif capture_id == "policy_invariants": - invariants_digest = body_digest - preconditions["policyInvariantsDigest"] = body_digest - manifest_records.append({"captureId": capture_id, "bodyPath": path.name, "digest": body_digest}) - evidence.append({"captureId": capture_id, "digest": body_digest, "locator": {"kind": "json_pointer", "value": pointer}}) - event.write_text(json.dumps(contract) + "\n") - manifest = root / "capture.json" - manifest.write_text(json.dumps({"schemaVersion": 1, "captures": manifest_records}) + "\n") - digests = { - "shared": digest_file(shared), - "phaseTemplate": digest_file(phase), - "eventContract": digest_file(event), - } - plan = { - "schemaVersion": 1, + "allowedPaths": {"mise-php": ["support-snapshot.json", "lib/policy.lua"]}, + }, + "result": { + "instructionDigests": digests, + "phaseStatus": "complete", + "criteria": [{"id": "done", "status": "passed", "evidence": ["preconditions.misePhpHead"]}], + "unresolved": [], + "goNoGo": "go", + }, + "contract": { "actionKey": "new_branch:8.6", - "action": "new_branch", - "agentContract": {"instructionDigests": digests}, - "completionAssessment": { - "instructionDigests": digests, - "phaseStatus": "complete", - "criteria": [{"id": "done", "status": "passed", "evidence": ["evidence[0]"]}], - "unresolved": [], - "goNoGo": "go", - }, "preconditions": preconditions, - "evidence": evidence, - "repositories": ["mise-php"], - "editsRequired": True, - "allowedPaths": {"mise-php": ["support-snapshot.json"]}, - "requiredChecks": ["Plugin contract"], - "risk": "lifecycle", - "agentOperations": [], - "budgets": {"maxModelCalls": 1, "maxRetries": 1, "timeoutMinutes": 30}, - } - result = admit( - plan, contract, shared, phase, event, manifest, - policy_digest, invariants_digest, preconditions["misePhpHead"], + "completionCriteria": [{"id": "done"}], + }, + "policy_path": policy, + "output": root / "sealed", + } + + def test_snapshot_diff_requires_matching_policy_lua(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + accepted = ["8.3", "8.4", "8.5", "8.6"] + repo, base, arguments = self.seal_fixture(root, accepted, ["8.2", "8.3", "8.4", "8.5"]) + snapshot = repo / "support-snapshot.json" + document = json.loads(snapshot.read_text()) + document["maintainedBranches"] = accepted + snapshot.write_text(json.dumps(document) + "\n") + with self.assertRaises(AdmissionError) as ctx: + seal(repo, base, **arguments) + self.assertIn("policy.lua", str(ctx.exception)) + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(accepted)) + manifest = seal(repo, base, **arguments) + self.assertEqual( + ["lib/policy.lua", "support-snapshot.json"], [item["path"] for item in manifest["files"]] ) - self.assertTrue(result["admitted"]) - with self.assertRaises(AdmissionError): - admit( - plan, contract, shared, phase, event, manifest, - "sha256:" + "f" * 64, invariants_digest, preconditions["misePhpHead"], - ) + + def test_policy_lua_diff_requires_matching_snapshot(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + maintained = ["8.3", "8.4", "8.5", "8.6"] + repo, base, arguments = self.seal_fixture(root, maintained, maintained) + # A lone lib/policy.lua edit would widen the plugin's branch filter with no + # snapshot evidence that php-bin accepted the added branch. + (repo / "lib" / "policy.lua").write_text(self.generated_policy_lua(maintained + ["9.0"])) + with self.assertRaises(AdmissionError) as ctx: + seal(repo, base, **arguments) + self.assertIn("policy.lua", str(ctx.exception)) def test_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1]