diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f7cf2f..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 @@ -8,17 +9,32 @@ /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 /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 +/scripts/lib.sh @loadinglucian +/scripts/install-spc.sh @loadinglucian +/scripts/install-build-deps.sh @loadinglucian +/.spc-version @loadinglucian +/.spc-sha256 @loadinglucian 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/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 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 f6ec9d4..a620101 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 @@ -68,7 +72,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 }} @@ -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: @@ -163,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: | @@ -189,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/ @@ -197,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 @@ -211,9 +222,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 +307,6 @@ jobs: needs: [preflight, repair] runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - contents: read - actions: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -377,7 +382,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')" @@ -395,7 +400,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 }} @@ -439,7 +444,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" \ @@ -499,7 +504,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..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 }} @@ -104,11 +110,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 +135,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 +188,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 +221,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 +271,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 \ @@ -289,6 +295,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 @@ -332,7 +358,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 \ @@ -358,7 +384,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 @@ -413,23 +439,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 42c2caa..834af57 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 @@ -23,6 +27,9 @@ jobs: edits_required: ${{ steps.plan.outputs.edits_required }} base_sha: ${{ steps.plan.outputs.base_sha }} 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: @@ -47,9 +54,13 @@ 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_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 +78,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 +101,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 +116,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 +132,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" @@ -140,9 +151,11 @@ 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 + # 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 @@ -170,13 +183,190 @@ 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 + # 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: | + # 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 + # 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="$(./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." + 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 }}" + # 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=$? + # 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 + if [[ "$pushed" == "true" ]]; then + git push origin --delete "$branch" || true + fi + git worktree remove --force "$worktree" || true + git branch -D "$branch" || true + exit "$status" + } + # 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" + 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 -C "$worktree" rev-parse HEAD)" + digest="sha256:$(shasum -a 256 "$worktree/$event" | awk '{print $1}')" + gh auth setup-git + 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##*/}" + ./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" + # --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 + 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"' \ + "$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 \ + --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' + 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 \ @@ -206,37 +396,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 [[ "$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 - 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 + # 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 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}' \ @@ -249,8 +436,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 @@ -274,8 +480,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" @@ -283,17 +488,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 [[ "$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 @@ -303,13 +509,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." @@ -319,7 +525,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)" @@ -345,20 +551,19 @@ 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 \ --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" @@ -373,9 +578,21 @@ 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 + # !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 notify-failure: name: Notify actionable watcher failure 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..0a32710 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 @@ -24,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/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index a6f5b89..2f2aa1f 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 @@ -122,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() @@ -224,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]" diff --git a/AUTORELEASE.md b/AUTORELEASE.md index 65264b4..535a5dc 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/`. 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. `mise-php` draws the same line over its own paths; +its `AUTORELEASE.md` owns that list. + ```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.32` 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, 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 reviewed protected-control change, then rerun the normal workflow. diff --git a/README.md b/README.md index bbb5ba9..424287e 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 @@ -84,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 @@ -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/autorelease/_admission.py b/autorelease/_admission.py new file mode 100644 index 0000000..8745d7e --- /dev/null +++ b/autorelease/_admission.py @@ -0,0 +1,651 @@ +"""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_shape( + plan: dict[str, Any], + manifest_path: pathlib.Path, + 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") + 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", + ) + 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), + "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", + ) + 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", [])): + 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") + + +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(), + "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..6eb57f2 --- /dev/null +++ b/autorelease/_state.py @@ -0,0 +1,405 @@ +"""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" + ) + 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" + # 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, + "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. + + 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 "") + 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 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"}: + 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 b726724..285fcbc 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -4,1067 +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*)?$") -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 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]] = {} - comparable = [] - 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 - 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)), - 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(re.fullmatch(r"[0-9a-f]{40}", 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(re.fullmatch(r"[0-9a-f]{40}", 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( - 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 - - -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, -) -> dict[str, Any]: - incomplete = sorted( - event.get("actionKey") - for event in events - if event.get("state") != "complete" - ) - 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" - return { - "schemaVersion": 1, - "trigger": trigger, - "manifestDigest": manifest.get("manifestDigest"), - "incompleteActions": incomplete, - "modelCall": trigger != "quiet", - } - - -def retry_decision( - event: dict[str, Any], - failure_fingerprint: str, - max_attempts: int, -) -> dict[str, Any]: - 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]: - 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), @@ -1076,122 +126,10 @@ 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": "", - } - comparable = [ - { - "captureId": item["captureId"], - "status": item["status"], - "digest": item["digest"], - } - for item in captures - ] - manifest["manifestDigest"] = sha256_bytes(canonical_json(comparable)) - 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") + return value == "true" def cli_error(error: Exception) -> int: @@ -1224,6 +162,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) @@ -1240,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( @@ -1255,6 +213,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/protected-paths.json b/autorelease/protected-paths.json index 9bcd8b6..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/**", @@ -10,6 +11,7 @@ "schemas/**", "autorelease/**", "scripts/admit-autorelease-plan", + "scripts/assert-admission-checks", "scripts/capture-autorelease-evidence", "scripts/configure-github-autorelease", "scripts/dispatch-pr-checks", @@ -28,6 +30,17 @@ "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/*", + "scripts/lib.sh", + "scripts/install-spc.sh", + "scripts/install-build-deps.sh", + ".spc-version", + ".spc-sha256" ] } diff --git a/autorelease/verify.py b/autorelease/verify.py index b188c2f..4a46ba6 100755 --- a/autorelease/verify.py +++ b/autorelease/verify.py @@ -20,11 +20,13 @@ from autorelease.control import ( ControlError, + action_filename, audit_reconstruction, canonical_json, instruction_digest, mutation_allowed, notification_decision, + path_is_protected, release_transition, retry_decision, seal_patch, @@ -41,6 +43,34 @@ 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"' +) +# 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. + """ + # 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").rglob("*.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]: @@ -65,6 +95,56 @@ 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 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. + + 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() @@ -336,9 +416,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"] @@ -364,12 +446,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 +495,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") @@ -462,14 +591,84 @@ 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") + # 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)) - return ["coordination.json"] + (directory / "action-filenames.json").write_bytes( + canonical_json({key: action_filename(key) for key in action_keys}) + ) + (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() 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") @@ -484,8 +683,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]: @@ -537,20 +741,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( - '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", + 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( + 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 +776,20 @@ 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") + # 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" + ) + ) + ) + assert_true( + 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") evidence = json.loads(admin.read_text()) @@ -623,19 +853,60 @@ 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("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( - release_workflow.count("current-operator.json") >= 3, + 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) + 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( + all( + job_name == "release" + 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", ) + 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/docs/release-process.md b/docs/release-process.md index 3a71c6e..ac3269b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -1,16 +1,45 @@ # 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 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. 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. 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 \ 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}} } } }, 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$"} - } -} 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/build.sh b/scripts/build.sh index ac636a6..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 @@ -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/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/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() { diff --git a/scripts/package.sh b/scripts/package.sh index 53a279c..ea1b2ae 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 @@ -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/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() 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"]})) 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" 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/watch-autorelease-evidence b/scripts/watch-autorelease-evidence index 9791c70..3e9de95 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, @@ -39,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") @@ -51,12 +55,25 @@ 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, + 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 1e3246b..0956266 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -1,6 +1,8 @@ +import contextlib import io import json import pathlib +import re import runpy import subprocess import tarfile @@ -9,11 +11,15 @@ from unittest import mock from autorelease.control import ( + 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, @@ -35,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(): @@ -105,6 +119,93 @@ 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 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, + 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) + # 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 = { "contractVersion": 1, @@ -319,6 +420,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) @@ -390,6 +501,127 @@ 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( + "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"], + ) + 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"]) + # 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"]) + 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")) @@ -399,6 +631,26 @@ 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", + # 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): + 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.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] ci = (root / ".github/workflows/ci.yml").read_text() @@ -438,6 +690,155 @@ 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() + 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) + self.assertIn('".github/workflows/autorelease-watch.yml"', protected) + 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, 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: ${{ !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) + 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) + # 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. 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\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) + # 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_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"}, + {"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) + + # 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"] = [[]]