From 340ab17b35455acf7811bb2cfcc3b836d938603e Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Sun, 2 Aug 2026 12:23:40 +0300 Subject: [PATCH 1/4] refactor!: rename the guarded maintenance system to autorelease Rename every "guarded maintenance" identifier to "autorelease" across directories, workflows, scripts, schemas, controls, and prose, and move the system documentation out of README.md into a dedicated AUTORELEASE.md. The consumer reads php-bin's operator control by path, both from a sparse checkout and over the API. php-bin main still carries the pre-rename filename until its own autorelease PR merges, so both read sites try the new path and fall back to the old one. Remove that fallback once php-bin main has landed. BREAKING CHANGE: the MAINTENANCE_OWNER repository variable must be recreated as AUTORELEASE_OWNER, and the maintenance label renamed to autorelease, in the same window as this merge. The workflow filename changes from maintenance-consumer.yml to autorelease-consumer.yml, so any saved dispatch link must be updated. --- .github/CODEOWNERS | 6 +- .../implementation.md | 0 .../investigation.md | 0 .../{maintenance => autorelease}/repair.md | 0 .../{maintenance => autorelease}/shared.md | 4 +- ...-consumer.yml => autorelease-consumer.yml} | 342 +++++++++--------- .github/workflows/protected-controls.yml | 4 +- .gitignore | 2 +- AUTORELEASE.md | 74 ++++ README.md | 74 +--- .../.gitkeep | 0 {maintenance => autorelease}/__init__.py | 0 {maintenance => autorelease}/admission.py | 6 +- {maintenance => autorelease}/consumer.py | 6 +- .../protected-paths.json | 10 +- docs/admin-state/mise-php-after.json | 10 +- docs/repository-settings.md | 10 +- ...hema.json => autorelease-plan.schema.json} | 0 ...aintenance-plan => admit-autorelease-plan} | 2 +- scripts/consume-php-policy | 2 +- ...intenance-patch => seal-autorelease-patch} | 2 +- scripts/verify-merge-admission | 2 +- ...est_maintenance.py => test_autorelease.py} | 20 +- 23 files changed, 299 insertions(+), 277 deletions(-) rename .github/codex/{maintenance => autorelease}/implementation.md (100%) rename .github/codex/{maintenance => autorelease}/investigation.md (100%) rename .github/codex/{maintenance => autorelease}/repair.md (100%) rename .github/codex/{maintenance => autorelease}/shared.md (92%) rename .github/workflows/{maintenance-consumer.yml => autorelease-consumer.yml} (67%) create mode 100644 AUTORELEASE.md rename {maintenance-events => autorelease-events}/.gitkeep (100%) rename {maintenance => autorelease}/__init__.py (100%) rename {maintenance => autorelease}/admission.py (99%) rename {maintenance => autorelease}/consumer.py (98%) rename {maintenance => autorelease}/protected-paths.json (69%) rename schemas/{maintenance-plan.schema.json => autorelease-plan.schema.json} (100%) rename scripts/{admit-maintenance-plan => admit-autorelease-plan} (81%) rename scripts/{seal-maintenance-patch => seal-autorelease-patch} (81%) rename test/{test_maintenance.py => test_autorelease.py} (94%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e590378..469cf85 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,10 +3,10 @@ /.github/workflows/ @loadinglucian /.github/CODEOWNERS @loadinglucian /.codex/ @loadinglucian -/maintenance/ @loadinglucian +/autorelease/ @loadinglucian /schemas/ @loadinglucian -/scripts/admit-maintenance-plan @loadinglucian -/scripts/seal-maintenance-patch @loadinglucian +/scripts/admit-autorelease-plan @loadinglucian +/scripts/seal-autorelease-patch @loadinglucian /scripts/validate-codex-action-inputs @loadinglucian /scripts/validate-structured-output-schemas @loadinglucian /scripts/verify-merge-admission @loadinglucian diff --git a/.github/codex/maintenance/implementation.md b/.github/codex/autorelease/implementation.md similarity index 100% rename from .github/codex/maintenance/implementation.md rename to .github/codex/autorelease/implementation.md diff --git a/.github/codex/maintenance/investigation.md b/.github/codex/autorelease/investigation.md similarity index 100% rename from .github/codex/maintenance/investigation.md rename to .github/codex/autorelease/investigation.md diff --git a/.github/codex/maintenance/repair.md b/.github/codex/autorelease/repair.md similarity index 100% rename from .github/codex/maintenance/repair.md rename to .github/codex/autorelease/repair.md diff --git a/.github/codex/maintenance/shared.md b/.github/codex/autorelease/shared.md similarity index 92% rename from .github/codex/maintenance/shared.md rename to .github/codex/autorelease/shared.md index 22a20f3..c8f1f0e 100644 --- a/.github/codex/maintenance/shared.md +++ b/.github/codex/autorelease/shared.md @@ -1,6 +1,6 @@ -# Guarded PHP maintenance agent instructions +# Autorelease agent instructions -The overarching goal is one production maintenance system across +The overarching goal is one production autorelease system across `bigpixelrocket/php-bin` and `bigpixelrocket/mise-php` that detects upstream PHP release or lifecycle changes, prepares bounded repository work, coordinates both repositories, and permits deterministic controls to publish immutable, diff --git a/.github/workflows/maintenance-consumer.yml b/.github/workflows/autorelease-consumer.yml similarity index 67% rename from .github/workflows/maintenance-consumer.yml rename to .github/workflows/autorelease-consumer.yml index 9769649..fddd8e8 100644 --- a/.github/workflows/maintenance-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -9,7 +9,7 @@ permissions: contents: read concurrency: - group: mise-php-maintenance-consumer + group: mise-php-autorelease-consumer cancel-in-progress: false jobs: @@ -29,43 +29,49 @@ jobs: with: repository: Bigpixelrocket/php-bin path: php-operator-control - sparse-checkout: .github/maintenance-operator.json + sparse-checkout: | + .github/autorelease-operator.json + .github/maintenance-operator.json persist-credentials: false - name: Bind investigation to the live operator control id: operator run: | - state="$(jq -r .unattendedMutation php-operator-control/.github/maintenance-operator.json)" + # php-bin main still carries the pre-rename filename until its own + # autorelease PR merges. Drop this fallback once that has landed. + operator="php-operator-control/.github/autorelease-operator.json" + test -f "$operator" || operator="php-operator-control/.github/maintenance-operator.json" + state="$(jq -r .unattendedMutation "$operator")" test "$state" = "paused" || test "$state" = "enabled" echo "state=$state" >> "$GITHUB_OUTPUT" echo "commit=$(git -C php-operator-control rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Capture accepted public php-bin policy run: | - mkdir -p maintenance-run + mkdir -p autorelease-run ./scripts/consume-php-policy fetch \ - --output maintenance-run/support-policy.json \ - --invariants-output maintenance-run/policy-invariants.json \ - --commit-output maintenance-run/php-bin-main.json \ - --manifest maintenance-run/policy-capture.json + --output autorelease-run/support-policy.json \ + --invariants-output autorelease-run/policy-invariants.json \ + --commit-output autorelease-run/php-bin-main.json \ + --manifest autorelease-run/policy-capture.json - name: Compare only opaque policy and event digests id: compare run: | ./scripts/consume-php-policy compare \ - --policy maintenance-run/support-policy.json \ - --invariants maintenance-run/policy-invariants.json \ - --policy-commit maintenance-run/php-bin-main.json \ + --policy autorelease-run/support-policy.json \ + --invariants autorelease-run/policy-invariants.json \ + --policy-commit autorelease-run/php-bin-main.json \ --snapshot support-snapshot.json \ - --events maintenance-events \ - --output maintenance-run/decision.json - echo "trigger=$(jq -r .trigger maintenance-run/decision.json)" >> "$GITHUB_OUTPUT" + --events autorelease-events \ + --output autorelease-run/decision.json + echo "trigger=$(jq -r .trigger autorelease-run/decision.json)" >> "$GITHUB_OUTPUT" - name: Prepare exact investigation contract if: steps.compare.outputs.trigger != 'quiet' && steps.operator.outputs.state == 'enabled' run: | jq -n \ - --arg actionKey "$(jq -r .actionKey maintenance-run/decision.json)" \ + --arg actionKey "$(jq -r .actionKey autorelease-run/decision.json)" \ --arg misePhpHead "$(git rev-parse HEAD)" \ - --arg phpBinPolicyCommit "$(jq -r .phpBinPolicyCommit maintenance-run/decision.json)" \ - --arg supportPolicyDigest "$(jq -r .policyDigest maintenance-run/decision.json)" \ - --arg policyInvariantsDigest "$(jq -r .policyInvariantsDigest maintenance-run/decision.json)" \ + --arg phpBinPolicyCommit "$(jq -r .phpBinPolicyCommit autorelease-run/decision.json)" \ + --arg supportPolicyDigest "$(jq -r .policyDigest autorelease-run/decision.json)" \ + --arg policyInvariantsDigest "$(jq -r .policyInvariantsDigest autorelease-run/decision.json)" \ --arg phpBinOperatorCommit "${{ steps.operator.outputs.commit }}" \ --arg operatorState "${{ steps.operator.outputs.state }}" \ '{ @@ -83,21 +89,21 @@ jobs: {id:"no-unresolved-work",requirement:"No contradiction or stop condition remains.",evidenceRequired:"Empty unresolved list."} ], stopConditions:["missing_or_contradictory_policy","changed_precondition","required_protected_change"] - }' > maintenance-run/event-contract.json - shared="sha256:$(shasum -a 256 .github/codex/maintenance/shared.md | awk '{print $1}')" - phase="sha256:$(shasum -a 256 .github/codex/maintenance/investigation.md | awk '{print $1}')" - event="sha256:$(shasum -a 256 maintenance-run/event-contract.json | awk '{print $1}')" + }' > autorelease-run/event-contract.json + shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" + phase="sha256:$(shasum -a 256 .github/codex/autorelease/investigation.md | awk '{print $1}')" + event="sha256:$(shasum -a 256 autorelease-run/event-contract.json | awk '{print $1}')" { - cat .github/codex/maintenance/shared.md - cat .github/codex/maintenance/investigation.md + cat .github/codex/autorelease/shared.md + cat .github/codex/autorelease/investigation.md echo echo '# Event contract' - jq . maintenance-run/event-contract.json + jq . autorelease-run/event-contract.json echo echo '# Required instruction digests' jq -n --arg shared "$shared" --arg phaseTemplate "$phase" --arg eventContract "$event" \ '{shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' - } > maintenance-run/investigation-prompt.md + } > autorelease-run/investigation-prompt.md mkdir -p "$RUNNER_TEMP/codex-home" cp .codex/investigation.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run repository-scoped offline Codex investigation @@ -106,40 +112,40 @@ jobs: with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} codex-version: 0.145.0 - prompt-file: maintenance-run/investigation-prompt.md - output-file: maintenance-run/maintenance-plan.json + prompt-file: autorelease-run/investigation-prompt.md + output-file: autorelease-run/autorelease-plan.json codex-home: ${{ runner.temp }}/codex-home sandbox: read-only safety-strategy: drop-sudo allow-users: loadinglucian allow-bots: false allow-bot-users: github-actions[bot] - codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/maintenance-plan.schema.json"]' + codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/autorelease-plan.schema.json"]' - name: Admit plan against exact policy and checkout if: steps.compare.outputs.trigger != 'quiet' && steps.operator.outputs.state == 'enabled' run: | - ./scripts/admit-maintenance-plan \ - --plan maintenance-run/maintenance-plan.json \ - --contract maintenance-run/event-contract.json \ - --shared .github/codex/maintenance/shared.md \ - --phase .github/codex/maintenance/investigation.md \ - --event-contract maintenance-run/event-contract.json \ - --capture-manifest maintenance-run/policy-capture.json \ - --policy-digest "$(jq -r .policyDigest maintenance-run/decision.json)" \ - --invariants-digest "$(jq -r .policyInvariantsDigest maintenance-run/decision.json)" \ + ./scripts/admit-autorelease-plan \ + --plan autorelease-run/autorelease-plan.json \ + --contract autorelease-run/event-contract.json \ + --shared .github/codex/autorelease/shared.md \ + --phase .github/codex/autorelease/investigation.md \ + --event-contract autorelease-run/event-contract.json \ + --capture-manifest autorelease-run/policy-capture.json \ + --policy-digest "$(jq -r .policyDigest autorelease-run/decision.json)" \ + --invariants-digest "$(jq -r .policyInvariantsDigest autorelease-run/decision.json)" \ --mise-head "$(git rev-parse HEAD)" \ - --output maintenance-run/admission.json + --output autorelease-run/admission.json - name: Expose plan id: plan if: steps.compare.outputs.trigger != 'quiet' && steps.operator.outputs.state == 'enabled' run: | - echo "edits_required=$(jq -r .editsRequired maintenance-run/maintenance-plan.json)" >> "$GITHUB_OUTPUT" + echo "edits_required=$(jq -r .editsRequired autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: mise-maintenance-investigation-${{ github.run_id }} - path: maintenance-run/ + name: mise-autorelease-investigation-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true @@ -160,8 +166,8 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mise-maintenance-investigation-${{ github.run_id }} - path: maintenance-run + name: mise-autorelease-investigation-${{ github.run_id }} + path: autorelease-run - name: Prepare implementation contract and prompt run: | jq \ @@ -175,23 +181,23 @@ jobs: {id:"advisory-checks-recorded",requirement:"All checks ran.",evidenceRequired:"Commands and results."}, {id:"no-unresolved-work",requirement:"No in-scope work remains.",evidenceRequired:"Empty unresolved list."} ]' \ - maintenance-run/event-contract.json > maintenance-run/implementation-contract.json - shared="sha256:$(shasum -a 256 .github/codex/maintenance/shared.md | awk '{print $1}')" - phase="sha256:$(shasum -a 256 .github/codex/maintenance/implementation.md | awk '{print $1}')" - event="sha256:$(shasum -a 256 maintenance-run/implementation-contract.json | awk '{print $1}')" + autorelease-run/event-contract.json > autorelease-run/implementation-contract.json + shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" + phase="sha256:$(shasum -a 256 .github/codex/autorelease/implementation.md | awk '{print $1}')" + event="sha256:$(shasum -a 256 autorelease-run/implementation-contract.json | awk '{print $1}')" { - cat .github/codex/maintenance/shared.md - cat .github/codex/maintenance/implementation.md + cat .github/codex/autorelease/shared.md + cat .github/codex/autorelease/implementation.md echo - jq . maintenance-run/implementation-contract.json + jq . autorelease-run/implementation-contract.json jq -n --arg shared "$shared" --arg phaseTemplate "$phase" --arg eventContract "$event" \ '{shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' echo - cat maintenance-run/maintenance-plan.json - } > maintenance-run/implementation-prompt.md + cat autorelease-run/autorelease-plan.json + } > autorelease-run/implementation-prompt.md jq --arg shared "$shared" --arg phaseTemplate "$phase" --arg eventContract "$event" \ '.agentContract.instructionDigests={shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' \ - maintenance-run/maintenance-plan.json > maintenance-run/implementation-plan.json + autorelease-run/autorelease-plan.json > autorelease-run/implementation-plan.json mkdir -p "$RUNNER_TEMP/codex-home" cp .codex/implementation.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run offline Codex implementation @@ -199,8 +205,8 @@ jobs: with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} codex-version: 0.145.0 - prompt-file: maintenance-run/implementation-prompt.md - output-file: maintenance-run/implementation-result.json + prompt-file: autorelease-run/implementation-prompt.md + output-file: autorelease-run/implementation-result.json codex-home: ${{ runner.temp }}/codex-home sandbox: workspace-write safety-strategy: drop-sudo @@ -210,18 +216,18 @@ jobs: codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal admitted diff run: | - ./scripts/seal-maintenance-patch \ + ./scripts/seal-autorelease-patch \ --repo . \ --base "${{ needs.investigate.outputs.base_sha }}" \ - --plan maintenance-run/implementation-plan.json \ - --result maintenance-run/implementation-result.json \ - --contract maintenance-run/implementation-contract.json \ - --policy maintenance-run/support-policy.json \ - --output maintenance-run/sealed + --plan autorelease-run/implementation-plan.json \ + --result autorelease-run/implementation-result.json \ + --contract autorelease-run/implementation-contract.json \ + --policy autorelease-run/support-policy.json \ + --output autorelease-run/sealed - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mise-sealed-maintenance-patch-${{ github.run_id }} - path: maintenance-run/ + name: mise-sealed-autorelease-patch-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true @@ -243,20 +249,20 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mise-sealed-maintenance-patch-${{ github.run_id }} - path: maintenance-run + name: mise-sealed-autorelease-patch-${{ github.run_id }} + path: autorelease-run - name: Apply exact sealed patch in clean checkout run: | - expected="$(jq -r .patchDigest maintenance-run/sealed/patch-manifest.json)" - actual="sha256:$(shasum -a 256 maintenance-run/sealed/sealed.patch | awk '{print $1}')" + expected="$(jq -r .patchDigest autorelease-run/sealed/patch-manifest.json)" + actual="sha256:$(shasum -a 256 autorelease-run/sealed/sealed.patch | awk '{print $1}')" test "$actual" = "$expected" - git apply --index maintenance-run/sealed/sealed.patch + git apply --index autorelease-run/sealed/sealed.patch - name: Run authoritative plugin checks without OpenAI credential id: checks run: | test -z "${OPENAI_API_KEY:-}" set +e - ./scripts/test.sh 2>&1 | tee maintenance-run/authoritative-checks.log + ./scripts/test.sh 2>&1 | tee autorelease-run/authoritative-checks.log status="${PIPESTATUS[0]}" set -e if [[ "$status" == "0" ]]; then @@ -267,29 +273,29 @@ jobs: - name: Create reproducible validated commit bundle if: steps.checks.outputs.passed == 'true' run: | - export GIT_AUTHOR_NAME=maintenance-validator - export GIT_AUTHOR_EMAIL=maintenance@invalid - export GIT_COMMITTER_NAME=maintenance-validator - export GIT_COMMITTER_EMAIL=maintenance@invalid + export GIT_AUTHOR_NAME=autorelease-validator + export GIT_AUTHOR_EMAIL=autorelease@invalid + export GIT_COMMITTER_NAME=autorelease-validator + export GIT_COMMITTER_EMAIL=autorelease@invalid export GIT_AUTHOR_DATE=2000-01-01T00:00:00Z export GIT_COMMITTER_DATE=2000-01-01T00:00:00Z - git commit -m "chore: apply admitted maintenance patch" + git commit -m "chore: apply admitted autorelease patch" jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" \ - '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > maintenance-run/validation.json - git bundle create maintenance-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" + '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json + git bundle create autorelease-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: steps.checks.outputs.passed == 'true' with: - name: mise-validated-maintenance-patch-${{ github.run_id }} - path: maintenance-run/ + name: mise-validated-autorelease-patch-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: steps.checks.outputs.passed != 'true' with: - name: mise-failed-maintenance-validation-${{ github.run_id }} - path: maintenance-run/ + name: mise-failed-autorelease-validation-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true @@ -310,12 +316,12 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mise-failed-maintenance-validation-${{ github.run_id }} - path: maintenance-run + name: mise-failed-autorelease-validation-${{ github.run_id }} + path: autorelease-run - name: Restore failed patch and prepare bounded repair run: | - git apply --index maintenance-run/sealed/sealed.patch - fingerprint="sha256:$(shasum -a 256 maintenance-run/authoritative-checks.log | awk '{print $1}')" + git apply --index autorelease-run/sealed/sealed.patch + fingerprint="sha256:$(shasum -a 256 autorelease-run/authoritative-checks.log | awk '{print $1}')" jq --arg goal "Repair the retained authoritative failure $fingerprint without expanding the admitted diff." \ '.phase="repair" | .goal=$goal @@ -327,23 +333,23 @@ jobs: {id:"failure-cause-removed",requirement:"The named failure is removed without weakening a gate.",evidenceRequired:"Minimal admitted diff and reproduction evidence."}, {id:"advisory-checks-recorded",requirement:"All advisory checks ran.",evidenceRequired:"Commands and results."}, {id:"no-unresolved-work",requirement:"No repeated or unrelated failure remains.",evidenceRequired:"Empty unresolved list."} - ]' maintenance-run/implementation-contract.json > maintenance-run/repair-contract.json - shared="sha256:$(shasum -a 256 .github/codex/maintenance/shared.md | awk '{print $1}')" - phase="sha256:$(shasum -a 256 .github/codex/maintenance/repair.md | awk '{print $1}')" - event="sha256:$(shasum -a 256 maintenance-run/repair-contract.json | awk '{print $1}')" + ]' autorelease-run/implementation-contract.json > autorelease-run/repair-contract.json + shared="sha256:$(shasum -a 256 .github/codex/autorelease/shared.md | awk '{print $1}')" + phase="sha256:$(shasum -a 256 .github/codex/autorelease/repair.md | awk '{print $1}')" + event="sha256:$(shasum -a 256 autorelease-run/repair-contract.json | awk '{print $1}')" jq --arg shared "$shared" --arg phaseTemplate "$phase" --arg eventContract "$event" \ '.agentContract.instructionDigests={shared:$shared,phaseTemplate:$phaseTemplate,eventContract:$eventContract}' \ - maintenance-run/implementation-plan.json > maintenance-run/repair-plan.json + autorelease-run/implementation-plan.json > autorelease-run/repair-plan.json { - cat .github/codex/maintenance/shared.md - cat .github/codex/maintenance/repair.md - jq . maintenance-run/repair-contract.json + cat .github/codex/autorelease/shared.md + cat .github/codex/autorelease/repair.md + jq . autorelease-run/repair-contract.json printf '\n# Retained authoritative failure log\n```text\n' - cat maintenance-run/authoritative-checks.log + cat autorelease-run/authoritative-checks.log printf '\n```\n# Exact admitted plan\n```json\n' - jq . maintenance-run/repair-plan.json + jq . autorelease-run/repair-plan.json printf '\n```\n' - } > maintenance-run/repair-prompt.md + } > autorelease-run/repair-prompt.md mkdir -p "$RUNNER_TEMP/codex-home" cp .codex/repair.config.toml "$RUNNER_TEMP/codex-home/config.toml" - name: Run one offline Codex repair @@ -351,8 +357,8 @@ jobs: with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} codex-version: 0.145.0 - prompt-file: maintenance-run/repair-prompt.md - output-file: maintenance-run/repair-result.json + prompt-file: autorelease-run/repair-prompt.md + output-file: autorelease-run/repair-result.json codex-home: ${{ runner.temp }}/codex-home sandbox: workspace-write safety-strategy: drop-sudo @@ -362,19 +368,19 @@ jobs: codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal combined repaired diff run: | - mv maintenance-run/repair-plan.json maintenance-run/implementation-plan.json - ./scripts/seal-maintenance-patch \ + mv autorelease-run/repair-plan.json autorelease-run/implementation-plan.json + ./scripts/seal-autorelease-patch \ --repo . \ --base "${{ needs.investigate.outputs.base_sha }}" \ - --plan maintenance-run/implementation-plan.json \ - --result maintenance-run/repair-result.json \ - --contract maintenance-run/repair-contract.json \ - --policy maintenance-run/support-policy.json \ - --output maintenance-run/sealed-repair + --plan autorelease-run/implementation-plan.json \ + --result autorelease-run/repair-result.json \ + --contract autorelease-run/repair-contract.json \ + --policy autorelease-run/support-policy.json \ + --output autorelease-run/sealed-repair - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mise-repaired-maintenance-patch-${{ github.run_id }} - path: maintenance-run/ + name: mise-repaired-autorelease-patch-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true @@ -394,34 +400,34 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mise-repaired-maintenance-patch-${{ github.run_id }} - path: maintenance-run + name: mise-repaired-autorelease-patch-${{ github.run_id }} + path: autorelease-run - name: Apply and validate exact repaired bytes run: | - expected="$(jq -r .patchDigest maintenance-run/sealed-repair/patch-manifest.json)" - actual="sha256:$(shasum -a 256 maintenance-run/sealed-repair/sealed.patch | awk '{print $1}')" + expected="$(jq -r .patchDigest autorelease-run/sealed-repair/patch-manifest.json)" + actual="sha256:$(shasum -a 256 autorelease-run/sealed-repair/sealed.patch | awk '{print $1}')" test "$actual" = "$expected" - git apply --index maintenance-run/sealed-repair/sealed.patch + git apply --index autorelease-run/sealed-repair/sealed.patch test -z "${OPENAI_API_KEY:-}" ./scripts/test.sh - cp maintenance-run/sealed-repair/sealed.patch maintenance-run/sealed/sealed.patch - cp maintenance-run/sealed-repair/patch-manifest.json maintenance-run/sealed/patch-manifest.json + cp autorelease-run/sealed-repair/sealed.patch autorelease-run/sealed/sealed.patch + cp autorelease-run/sealed-repair/patch-manifest.json autorelease-run/sealed/patch-manifest.json - name: Create repaired validated commit bundle run: | - export GIT_AUTHOR_NAME=maintenance-validator - export GIT_AUTHOR_EMAIL=maintenance@invalid - export GIT_COMMITTER_NAME=maintenance-validator - export GIT_COMMITTER_EMAIL=maintenance@invalid + export GIT_AUTHOR_NAME=autorelease-validator + export GIT_AUTHOR_EMAIL=autorelease@invalid + export GIT_COMMITTER_NAME=autorelease-validator + export GIT_COMMITTER_EMAIL=autorelease@invalid export GIT_AUTHOR_DATE=2000-01-01T00:00:00Z export GIT_COMMITTER_DATE=2000-01-01T00:00:00Z - git commit -m "chore: apply admitted maintenance repair" + git commit -m "chore: apply admitted autorelease repair" jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" \ - '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > maintenance-run/validation.json - git bundle create maintenance-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" + '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json + git bundle create autorelease-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mise-validated-maintenance-patch-${{ github.run_id }} - path: maintenance-run/ + name: mise-validated-autorelease-patch-${{ github.run_id }} + path: autorelease-run/ if-no-files-found: error retention-days: 90 include-hidden-files: true @@ -445,25 +451,25 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mise-validated-maintenance-patch-${{ github.run_id }} - path: maintenance-run + name: mise-validated-autorelease-patch-${{ github.run_id }} + path: autorelease-run - name: Restore and push exact validated commit id: patch env: GH_TOKEN: ${{ github.token }} run: | - validated="$(jq -r .headSha maintenance-run/validation.json)" - git fetch maintenance-run/validated.bundle "$validated" + validated="$(jq -r .headSha autorelease-run/validation.json)" + git fetch autorelease-run/validated.bundle "$validated" git checkout --detach "$validated" test "$(git rev-parse HEAD)" = "$validated" - action_key="$(jq -r .actionKey maintenance-run/implementation-plan.json)" - branch="maintenance/$(printf '%s' "$action_key" | tr ':/' '--')" + action_key="$(jq -r .actionKey autorelease-run/implementation-plan.json)" + branch="autorelease/$(printf '%s' "$action_key" | tr ':/' '--')" gh auth setup-git git push origin "HEAD:refs/heads/$branch" number="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')" if [[ -z "$number" ]]; then url="$(gh pr create --base main --head "$branch" --title "chore: $action_key" \ - --body "Deterministically sealed mise-php maintenance patch.\n\nValidated commit: \`$validated\`.")" + --body "Deterministically sealed mise-php autorelease patch.\n\nValidated commit: \`$validated\`.")" number="${url##*/}" fi echo "number=$number" >> "$GITHUB_OUTPUT" @@ -475,27 +481,31 @@ jobs: ./scripts/dispatch-pr-checks \ --pr "${{ steps.patch.outputs.number }}" \ --check "Plugin contract" \ - --output maintenance-run/pr-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' maintenance-run/pr-checks.json - expected="$(jq -r .headSha maintenance-run/validation.json)" + --output autorelease-run/pr-checks.json + jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/pr-checks.json + expected="$(jq -r .headSha autorelease-run/validation.json)" actual="$(gh pr view "${{ steps.patch.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "$expected" test "$(git rev-parse HEAD)" = "$expected" git fetch origin main ./scripts/consume-php-policy fetch \ - --output maintenance-run/current-support-policy.json \ - --invariants-output maintenance-run/current-policy-invariants.json \ - --commit-output maintenance-run/current-php-bin-main.json \ - --manifest maintenance-run/current-policy-capture.json - admitted_mise="$(jq -r .preconditions.misePhpHead maintenance-run/implementation-plan.json)" - admitted_php="$(jq -r .preconditions.phpBinPolicyCommit maintenance-run/implementation-plan.json)" - admitted_policy="$(jq -r .preconditions.supportPolicyDigest maintenance-run/implementation-plan.json)" - admitted_invariants="$(jq -r .preconditions.policyInvariantsDigest maintenance-run/implementation-plan.json)" - admitted_operator_commit="$(jq -r .preconditions.phpBinOperatorCommit maintenance-run/implementation-plan.json)" - admitted_operator_state="$(jq -r .preconditions.operatorState maintenance-run/implementation-plan.json)" + --output autorelease-run/current-support-policy.json \ + --invariants-output autorelease-run/current-policy-invariants.json \ + --commit-output autorelease-run/current-php-bin-main.json \ + --manifest autorelease-run/current-policy-capture.json + admitted_mise="$(jq -r .preconditions.misePhpHead autorelease-run/implementation-plan.json)" + admitted_php="$(jq -r .preconditions.phpBinPolicyCommit autorelease-run/implementation-plan.json)" + admitted_policy="$(jq -r .preconditions.supportPolicyDigest autorelease-run/implementation-plan.json)" + admitted_invariants="$(jq -r .preconditions.policyInvariantsDigest autorelease-run/implementation-plan.json)" + admitted_operator_commit="$(jq -r .preconditions.phpBinOperatorCommit autorelease-run/implementation-plan.json)" + admitted_operator_state="$(jq -r .preconditions.operatorState autorelease-run/implementation-plan.json)" current_operator_commit="$(gh api repos/Bigpixelrocket/php-bin/commits/main --jq .sha)" - gh api "repos/Bigpixelrocket/php-bin/contents/.github/maintenance-operator.json?ref=$current_operator_commit" \ - --jq .content | base64 --decode > maintenance-run/current-operator.json + # Same pre-rename fallback as the operator bind step above. + operator_path=".github/autorelease-operator.json" + gh api "repos/Bigpixelrocket/php-bin/contents/$operator_path?ref=$current_operator_commit" >/dev/null 2>&1 \ + || operator_path=".github/maintenance-operator.json" + gh api "repos/Bigpixelrocket/php-bin/contents/$operator_path?ref=$current_operator_commit" \ + --jq .content | base64 --decode > autorelease-run/current-operator.json jq -n \ --arg misePhpHead "$admitted_mise" \ --arg phpBinPolicyCommit "$admitted_php" \ @@ -504,24 +514,24 @@ jobs: --arg phpBinOperatorCommit "$admitted_operator_commit" \ --arg operatorState "$admitted_operator_state" \ '{misePhpHead:$misePhpHead,phpBinPolicyCommit:$phpBinPolicyCommit,supportPolicyDigest:$supportPolicyDigest,policyInvariantsDigest:$policyInvariantsDigest,phpBinOperatorCommit:$phpBinOperatorCommit,operatorState:$operatorState}' \ - > maintenance-run/preconditions.json + > autorelease-run/preconditions.json jq -n \ --arg misePhpHead "$(git rev-parse origin/main)" \ - --arg phpBinPolicyCommit "$(jq -r .sha maintenance-run/current-php-bin-main.json)" \ - --arg supportPolicyDigest "sha256:$(shasum -a 256 maintenance-run/current-support-policy.json | awk '{print $1}')" \ - --arg policyInvariantsDigest "sha256:$(shasum -a 256 maintenance-run/current-policy-invariants.json | awk '{print $1}')" \ + --arg phpBinPolicyCommit "$(jq -r .sha autorelease-run/current-php-bin-main.json)" \ + --arg supportPolicyDigest "sha256:$(shasum -a 256 autorelease-run/current-support-policy.json | awk '{print $1}')" \ + --arg policyInvariantsDigest "sha256:$(shasum -a 256 autorelease-run/current-policy-invariants.json | awk '{print $1}')" \ --arg phpBinOperatorCommit "$current_operator_commit" \ - --arg operatorState "$(jq -r .unattendedMutation maintenance-run/current-operator.json)" \ + --arg operatorState "$(jq -r .unattendedMutation autorelease-run/current-operator.json)" \ '{misePhpHead:$misePhpHead,phpBinPolicyCommit:$phpBinPolicyCommit,supportPolicyDigest:$supportPolicyDigest,policyInvariantsDigest:$policyInvariantsDigest,phpBinOperatorCommit:$phpBinOperatorCommit,operatorState:$operatorState}' \ - > maintenance-run/current.json - jq -n '{"Plugin contract":"success"}' > maintenance-run/checks.json + > autorelease-run/current.json + jq -n '{"Plugin contract":"success"}' > autorelease-run/checks.json ./scripts/verify-merge-admission \ --repo . \ --head "$expected" \ - --manifest maintenance-run/sealed/patch-manifest.json \ - --checks maintenance-run/checks.json \ - --preconditions maintenance-run/preconditions.json \ - --current maintenance-run/current.json + --manifest autorelease-run/sealed/patch-manifest.json \ + --checks autorelease-run/checks.json \ + --preconditions autorelease-run/preconditions.json \ + --current autorelease-run/current.json - name: Merge exact admitted patch env: GH_TOKEN: ${{ github.token }} @@ -534,28 +544,28 @@ jobs: merged="$(gh pr view "${{ steps.patch.outputs.number }}" --json mergeCommit --jq .mergeCommit.oid)" git fetch origin main test "$(git rev-parse origin/main)" = "$merged" - test "$(git rev-parse "$merged^{tree}")" = "$(jq -r .tree maintenance-run/validation.json)" - git checkout -B maintenance/readiness-${{ github.run_id }} origin/main + test "$(git rev-parse "$merged^{tree}")" = "$(jq -r .tree autorelease-run/validation.json)" + git checkout -B autorelease/readiness-${{ github.run_id }} origin/main base="$(git rev-parse HEAD)" mkdir -p readiness filename="$(printf '%s' "${{ steps.patch.outputs.action_key }}" | tr ':/' '--').json" - mapfile -t digests < <(jq -r '.captures[].digest' maintenance-run/policy-capture.json) + mapfile -t digests < <(jq -r '.captures[].digest' autorelease-run/policy-capture.json) args=() for digest in "${digests[@]}"; do args+=(--evidence-digest "$digest"); done ./scripts/consume-php-policy readiness \ --action-key "${{ steps.patch.outputs.action_key }}" \ - --php-bin-commit "$(jq -r .preconditions.phpBinPolicyCommit maintenance-run/implementation-plan.json)" \ - --policy-digest "$(jq -r .preconditions.supportPolicyDigest maintenance-run/implementation-plan.json)" \ - --policy-invariants-digest "$(jq -r .preconditions.policyInvariantsDigest maintenance-run/implementation-plan.json)" \ + --php-bin-commit "$(jq -r .preconditions.phpBinPolicyCommit autorelease-run/implementation-plan.json)" \ + --policy-digest "$(jq -r .preconditions.supportPolicyDigest autorelease-run/implementation-plan.json)" \ + --policy-invariants-digest "$(jq -r .preconditions.policyInvariantsDigest autorelease-run/implementation-plan.json)" \ --mise-commit "$merged" \ "${args[@]}" \ --output "readiness/$filename" git add "readiness/$filename" - git -c user.name=maintenance-readiness -c user.email=maintenance@invalid \ + git -c user.name=autorelease-readiness -c user.email=autorelease@invalid \ commit -m "chore: record ${{ steps.patch.outputs.action_key }} readiness" gh auth setup-git git push origin HEAD - url="$(gh pr create --base main --head "maintenance/readiness-${{ github.run_id }}" \ + url="$(gh pr create --base main --head "autorelease/readiness-${{ github.run_id }}" \ --title "chore: record ${{ steps.patch.outputs.action_key }} readiness" \ --body "Deterministic readiness record for the exact merged implementation commit.")" echo "number=${url##*/}" >> "$GITHUB_OUTPUT" @@ -570,8 +580,8 @@ jobs: ./scripts/dispatch-pr-checks \ --pr "${{ steps.readiness.outputs.number }}" \ --check "Plugin contract" \ - --output maintenance-run/readiness-checks.json - jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' maintenance-run/readiness-checks.json + --output autorelease-run/readiness-checks.json + jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' 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/protected-controls.yml b/.github/workflows/protected-controls.yml index 937822b..b762686 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -49,7 +49,7 @@ jobs: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - PROTECTED_REVIEWER: ${{ vars.MAINTENANCE_OWNER }} + PROTECTED_REVIEWER: ${{ vars.AUTORELEASE_OWNER }} run: | python3 - <<'PY' import fnmatch @@ -75,7 +75,7 @@ jobs: number = os.environ["PR_NUMBER"] head = os.environ["HEAD_SHA"] reviewer = os.environ["PROTECTED_REVIEWER"].lower() - manifest = json.loads(pathlib.Path("maintenance/protected-paths.json").read_text()) + manifest = json.loads(pathlib.Path("autorelease/protected-paths.json").read_text()) patterns = manifest["patterns"] files = api(f"repos/{repo}/pulls/{number}/files") protected = sorted( diff --git a/.gitignore b/.gitignore index 3cf05f4..db830d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store __pycache__/ *.py[cod] -maintenance-run/ +autorelease-run/ .test-tmp/ diff --git a/AUTORELEASE.md b/AUTORELEASE.md new file mode 100644 index 0000000..b30b9c2 --- /dev/null +++ b/AUTORELEASE.md @@ -0,0 +1,74 @@ +# Autorelease + +How this repository consumes the accepted `php-bin` support policy, +prepares bounded repository work, and records the exact-commit readiness +that `php-bin` requires before it may publish a new branch. + +The scheduled `php-bin policy consumer` captures the accepted public +`support-policy.json` and compares only its digest and incomplete-event state +with `support-snapshot.json`. It does not fetch or classify upstream PHP +lifecycle data. When the exact policy changes, the repository-scoped pinned +Codex Action produces an evidence-bound plan. Any implementation runs offline, +without a GitHub write credential, and only against admitted paths. + +```mermaid +flowchart TD + policy["Accepted php-bin policy commit and digest"] --> compare{"Snapshot differs?"} + compare -- "No" --> quiet["Quiet: no model call or mutation"] + compare -- "Yes" --> plan["Offline repository-scoped Codex plan"] + plan --> admit["Deterministic admission"] + admit --> patch["Offline admitted implementation"] + patch --> seal["Seal paths and digests"] + seal --> test["Clean macOS arm64 plugin tests"] + test --> ready["Commit exact mise_ready record"] + ready --> release["php-bin verifies both readiness records"] +``` + +Only maintained branches appear in `mise ls-remote` or resolve from a branch +shorthand. An exact historical stable version may still install when its +immutable `php-bin` release and checksum assets exist. New branch publication +waits for matching `php_bin_ready` and `mise_ready` records at exact commits. + +Failures and lifecycle transitions use one deduplicated GitHub issue per action +key, assigned through `AUTORELEASE_OWNER`. Comments are added only for meaningful +changes, and GitHub Actions failure email remains an independent fallback. + +```mermaid +flowchart TD + phase["Consumer, agent, sealing, test, or readiness phase"] --> result{"Result"} + result -- "Passed" --> state["Record exact evidence and state"] + result -- "Retryable" --> bounded["Bounded repair"] + result -- "Critical, repeated, or exhausted" --> stop["Stop mutation"] + bounded --> result + stop --> issue["Assigned autorelease issue"] + issue --> email["GitHub issue email"] + stop --> actions["Actions failure email"] +``` + +Pause unattended mutation in the reviewed +`php-bin/.github/autorelease-operator.json` control. Read-only capture and +investigation remain available while paused. Resume through a reviewed change; +partial events continue only through the deterministic next transition. + +From a checkout containing both repositories: + +```bash +(cd php-bin && ./scripts/test.sh) +(cd mise-php && ./scripts/test.sh) + +./php-bin/scripts/verify-autorelease-system \ + --mise-repo ./mise-php \ + --php-bin-sha \ + --mise-php-sha \ + --output ./verification-results +``` + +Each repository's `scripts/test.sh` also validates every pinned Codex Action +invocation, exact CLI version, and canonical `config.toml` loading against the +reviewed offline contract in `.github/codex-action-contract.json` before +exercising autorelease behavior. + +Inspect `support-snapshot.json`, `autorelease-events/`, `readiness/`, retained +workflow artifacts, and the event's GitHub issue. Recovery corrects the cause +and reruns the normal admitted path; it never disables checksum, policy, +sealing, exact-SHA, or publication gates. diff --git a/README.md b/README.md index b0e978e..0133634 100644 --- a/README.md +++ b/README.md @@ -24,76 +24,14 @@ that have reached end of life are intentionally not listed or installable. Other operating systems and Intel Macs receive an explicit unsupported-target error. Older macOS releases cannot load the published binaries. -## Guarded automatic maintenance - -The scheduled `php-bin policy consumer` captures the accepted public -`support-policy.json` and compares only its digest and incomplete-event state -with `support-snapshot.json`. It does not fetch or classify upstream PHP -lifecycle data. When the exact policy changes, the repository-scoped pinned -Codex Action produces an evidence-bound plan. Any implementation runs offline, -without a GitHub write credential, and only against admitted paths. - -```mermaid -flowchart TD - policy["Accepted php-bin policy commit and digest"] --> compare{"Snapshot differs?"} - compare -- "No" --> quiet["Quiet: no model call or mutation"] - compare -- "Yes" --> plan["Offline repository-scoped Codex plan"] - plan --> admit["Deterministic admission"] - admit --> patch["Offline admitted implementation"] - patch --> seal["Seal paths and digests"] - seal --> test["Clean macOS arm64 plugin tests"] - test --> ready["Commit exact mise_ready record"] - ready --> release["php-bin verifies both readiness records"] -``` - -Only maintained branches appear in `mise ls-remote` or resolve from a branch -shorthand. An exact historical stable version may still install when its -immutable `php-bin` release and checksum assets exist. New branch publication -waits for matching `php_bin_ready` and `mise_ready` records at exact commits. - -Failures and lifecycle transitions use one deduplicated GitHub issue per action -key, assigned through `MAINTENANCE_OWNER`. Comments are added only for meaningful -changes, and GitHub Actions failure email remains an independent fallback. - -```mermaid -flowchart TD - phase["Consumer, agent, sealing, test, or readiness phase"] --> result{"Result"} - result -- "Passed" --> state["Record exact evidence and state"] - result -- "Retryable" --> bounded["Bounded repair"] - result -- "Critical, repeated, or exhausted" --> stop["Stop mutation"] - bounded --> result - stop --> issue["Assigned maintenance issue"] - issue --> email["GitHub issue email"] - stop --> actions["Actions failure email"] -``` - -Pause unattended mutation in the reviewed -`php-bin/.github/maintenance-operator.json` control. Read-only capture and -investigation remain available while paused. Resume through a reviewed change; -partial events continue only through the deterministic next transition. - -From a checkout containing both repositories: - -```bash -(cd php-bin && ./scripts/test.sh) -(cd mise-php && ./scripts/test.sh) - -./php-bin/scripts/verify-maintenance-system \ - --mise-repo ./mise-php \ - --php-bin-sha \ - --mise-php-sha \ - --output ./verification-results -``` +## Autorelease -Each repository's `scripts/test.sh` also validates every pinned Codex Action -invocation, exact CLI version, and canonical `config.toml` loading against the -reviewed offline contract in `.github/codex-action-contract.json` before -exercising maintenance behavior. +This repository tracks `php-bin` automatically. A daily consumer captures the +accepted public support policy, and deterministic workflows admit, seal, and +merge any required change, then record exact-commit readiness. -Inspect `support-snapshot.json`, `maintenance-events/`, `readiness/`, retained -workflow artifacts, and the event's GitHub issue. Recovery corrects the cause -and reruns the normal admitted path; it never disables checksum, policy, -sealing, exact-SHA, or publication gates. +See [AUTORELEASE.md](AUTORELEASE.md) for the full contract, the operator +pause control, and maintainer commands. ## Install diff --git a/maintenance-events/.gitkeep b/autorelease-events/.gitkeep similarity index 100% rename from maintenance-events/.gitkeep rename to autorelease-events/.gitkeep diff --git a/maintenance/__init__.py b/autorelease/__init__.py similarity index 100% rename from maintenance/__init__.py rename to autorelease/__init__.py diff --git a/maintenance/admission.py b/autorelease/admission.py similarity index 99% rename from maintenance/admission.py rename to autorelease/admission.py index 2512700..cd40144 100755 --- a/maintenance/admission.py +++ b/autorelease/admission.py @@ -133,12 +133,12 @@ def admit( "no_change", "new_patch", "new_branch", "branch_eol", "repair", "reconcile_partial", "blocked", "needs_human", }: - raise AdmissionError("invalid maintenance action") + raise AdmissionError("invalid autorelease action") if plan.get("action") in {"blocked", "needs_human"}: raise AdmissionError("no-go plan cannot advance") action_key = plan.get("actionKey", "") if not ACTION_KEY_RE.fullmatch(action_key): - raise AdmissionError("invalid maintenance action key") + raise AdmissionError("invalid autorelease action key") if action_key != contract.get("actionKey"): raise AdmissionError("plan action key changed from the event contract") if action_key.startswith("new_branch:") and plan.get("action") != "new_branch": @@ -495,7 +495,7 @@ def main() -> int: print(json.dumps(value)) return 0 except (AdmissionError, OSError, subprocess.CalledProcessError) as error: - print(f"mise maintenance admission rejected: {error}", file=sys.stderr) + print(f"mise autorelease admission rejected: {error}", file=sys.stderr) return 1 diff --git a/maintenance/consumer.py b/autorelease/consumer.py similarity index 98% rename from maintenance/consumer.py rename to autorelease/consumer.py index 3edd756..b14f99d 100755 --- a/maintenance/consumer.py +++ b/autorelease/consumer.py @@ -79,7 +79,7 @@ def fetch_url(url: str, output: pathlib.Path) -> dict[str, Any]: raise ConsumerError("policy capture URL is outside the reviewed HTTPS allowlist") request = urllib.request.Request( url, - headers={"Accept": "application/json", "User-Agent": "bigpixelrocket-maintenance/1"}, + headers={"Accept": "application/json", "User-Agent": "bigpixelrocket-autorelease/1"}, ) opener = urllib.request.build_opener(RestrictedRedirect) last_error: Exception | None = None @@ -112,7 +112,7 @@ def pinned_policy_urls(commit_sha: str) -> tuple[str, str]: raise ConsumerError("php-bin main state has no exact commit") return ( f"{RAW_ROOT}/{commit_sha}/support-policy.json", - f"{RAW_ROOT}/{commit_sha}/maintenance/policy-invariants.json", + f"{RAW_ROOT}/{commit_sha}/autorelease/policy-invariants.json", ) @@ -350,7 +350,7 @@ def main() -> int: print(json.dumps(result)) return 0 except (ConsumerError, OSError, json.JSONDecodeError) as error: - print(f"maintenance consumer rejected input: {error}", file=sys.stderr) + print(f"autorelease consumer rejected input: {error}", file=sys.stderr) return 1 diff --git a/maintenance/protected-paths.json b/autorelease/protected-paths.json similarity index 69% rename from maintenance/protected-paths.json rename to autorelease/protected-paths.json index 20eb0fb..dfa8537 100644 --- a/maintenance/protected-paths.json +++ b/autorelease/protected-paths.json @@ -1,19 +1,19 @@ { "schemaVersion": 1, "patterns": [ - ".github/codex/maintenance/*", + ".github/codex/autorelease/*", ".github/codex-action-contract.json", ".github/workflows/*", ".codex/*", "schemas/*", - "maintenance/*", - "scripts/admit-maintenance-plan", + "autorelease/*", + "scripts/admit-autorelease-plan", "scripts/dispatch-pr-checks", - "scripts/seal-maintenance-patch", + "scripts/seal-autorelease-patch", "scripts/validate-codex-action-inputs", "scripts/validate-structured-output-schemas", "scripts/verify-merge-admission", - "maintenance-events/*", + "autorelease-events/*", "readiness/*", ".github/CODEOWNERS" ] diff --git a/docs/admin-state/mise-php-after.json b/docs/admin-state/mise-php-after.json index b6bf610..1da4daa 100644 --- a/docs/admin-state/mise-php-after.json +++ b/docs/admin-state/mise-php-after.json @@ -81,9 +81,9 @@ "custom_branch_policies": false, "protected_branches": true }, - "html_url": "https://github.com/Bigpixelrocket/mise-php/deployments/activity_log?environments_filter=php-maintenance-release", + "html_url": "https://github.com/Bigpixelrocket/mise-php/deployments/activity_log?environments_filter=php-autorelease-publish", "id": 18804850284, - "name": "php-maintenance-release", + "name": "php-autorelease-publish", "node_id": "EN_kwDOTj2ybM8AAAAEYNs-bA", "protection_rules": [ { @@ -93,7 +93,7 @@ } ], "updated_at": "2026-07-27T10:08:07Z", - "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/environments/php-maintenance-release" + "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/environments/php-autorelease-publish" } ], "total_count": 1 @@ -113,7 +113,7 @@ "good first issue", "help wanted", "invalid", - "maintenance", + "autorelease", "question", "wontfix" ], @@ -163,7 +163,7 @@ }, "snapshotDigest": "sha256:e50672433148e1054cd0436af11f47cbc7bf643db650eaed166ffcd1633fa632", "variables": [ - "MAINTENANCE_OWNER" + "AUTORELEASE_OWNER" ], "workflowPermissions": { "can_approve_pull_request_reviews": true, diff --git a/docs/repository-settings.md b/docs/repository-settings.md index 6c458f8..b68237a 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -1,7 +1,7 @@ # Repository settings The plan executor applies this state with the protected -`php-bin/scripts/configure-github-maintenance` command and verifies it with +`php-bin/scripts/configure-github-autorelease` command and verifies it with `php-bin/scripts/snapshot-github-admin-state`. Snapshots are redacted: only secret names are retained. @@ -11,7 +11,7 @@ Required repository state: - Require the `Plugin contract` status check. - Require the base-controlled `Protected controls` status check. It passes automatically for unprotected generated paths and requires an exact-head - `loadinglucian` approval for any path in `maintenance/protected-paths.json`. + `loadinglucian` approval for any path in `autorelease/protected-paths.json`. - Bind the required check to the GitHub Actions app, preventing another app from satisfying the same context name. - Require conversation resolution. @@ -33,9 +33,9 @@ Required repository state: - Allow GitHub-owned Actions plus only `openai/codex-action` and `jdx/mise-action`, and require every Action reference to use a full commit SHA. -- Create the protected `php-maintenance-release` environment. +- Create the protected `php-autorelease-publish` environment. - Enable GitHub immutable releases for future repository releases. -- Set `MAINTENANCE_OWNER=loadinglucian`. +- Set `AUTORELEASE_OWNER=loadinglucian`. - Keep a distinct repository-scoped `OPENAI_API_KEY` secret. CODEOWNERS protects agent instructions, workflows, schemas, admission, sealing, @@ -48,7 +48,7 @@ still rejects event/readiness paths as agent-authored changes. --repo bigpixelrocket/mise-php \ --output mise-php/docs/admin-state/mise-php.json -./php-bin/scripts/configure-github-maintenance \ +./php-bin/scripts/configure-github-autorelease \ --repo bigpixelrocket/mise-php \ --owner loadinglucian \ --required-check "Plugin contract" diff --git a/schemas/maintenance-plan.schema.json b/schemas/autorelease-plan.schema.json similarity index 100% rename from schemas/maintenance-plan.schema.json rename to schemas/autorelease-plan.schema.json diff --git a/scripts/admit-maintenance-plan b/scripts/admit-autorelease-plan similarity index 81% rename from scripts/admit-maintenance-plan rename to scripts/admit-autorelease-plan index 6960c49..05bdd14 100755 --- a/scripts/admit-maintenance-plan +++ b/scripts/admit-autorelease-plan @@ -3,7 +3,7 @@ import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) -from maintenance.admission import main +from autorelease.admission import main sys.argv.insert(1, "admit") raise SystemExit(main()) diff --git a/scripts/consume-php-policy b/scripts/consume-php-policy index f5cf8bc..0b2d75a 100755 --- a/scripts/consume-php-policy +++ b/scripts/consume-php-policy @@ -3,6 +3,6 @@ import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) -from maintenance.consumer import main +from autorelease.consumer import main raise SystemExit(main()) diff --git a/scripts/seal-maintenance-patch b/scripts/seal-autorelease-patch similarity index 81% rename from scripts/seal-maintenance-patch rename to scripts/seal-autorelease-patch index 5dcf234..b0087db 100755 --- a/scripts/seal-maintenance-patch +++ b/scripts/seal-autorelease-patch @@ -3,7 +3,7 @@ import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) -from maintenance.admission import main +from autorelease.admission import main sys.argv.insert(1, "seal") raise SystemExit(main()) diff --git a/scripts/verify-merge-admission b/scripts/verify-merge-admission index 95d0ea7..2a22dda 100755 --- a/scripts/verify-merge-admission +++ b/scripts/verify-merge-admission @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -exec python3 "$ROOT/maintenance/admission.py" verify-merge "$@" +exec python3 "$ROOT/autorelease/admission.py" verify-merge "$@" diff --git a/test/test_maintenance.py b/test/test_autorelease.py similarity index 94% rename from test/test_maintenance.py rename to test/test_autorelease.py index c3a6e26..93d4f2a 100644 --- a/test/test_maintenance.py +++ b/test/test_autorelease.py @@ -4,11 +4,11 @@ import unittest import json -from maintenance.admission import AdmissionError, admit, digest_file, protected, verify_merge -from maintenance.consumer import compare, digest, pinned_policy_urls, readiness, write +from autorelease.admission import AdmissionError, admit, digest_file, protected, verify_merge +from autorelease.consumer import compare, digest, pinned_policy_urls, readiness, write -class MaintenanceConsumerTests(unittest.TestCase): +class AutoreleaseConsumerTests(unittest.TestCase): def test_opaque_policy_comparison(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) @@ -63,17 +63,17 @@ def test_readiness_requires_exact_commits_and_digests(self): def test_protected_controls_are_not_admissible(self): self.assertTrue(protected(".github/codex-action-contract.json")) - self.assertTrue(protected(".github/workflows/maintenance.yml")) - self.assertTrue(protected("maintenance/admission.py")) + self.assertTrue(protected(".github/workflows/autorelease.yml")) + self.assertTrue(protected("autorelease/admission.py")) self.assertTrue(protected("scripts/validate-codex-action-inputs")) - self.assertTrue(protected("maintenance-events/new-patch.json")) + self.assertTrue(protected("autorelease-events/new-patch.json")) self.assertTrue(protected("readiness/new-branch.json")) self.assertFalse(protected("lib/releases.lua")) def test_investigation_defers_required_checks_to_writable_jobs(self): root = pathlib.Path(__file__).resolve().parents[1] - instructions = (root / ".github/codex/maintenance/investigation.md").read_text() - consumer = (root / ".github/workflows/maintenance-consumer.yml").read_text() + instructions = (root / ".github/codex/autorelease/investigation.md").read_text() + consumer = (root / ".github/workflows/autorelease-consumer.yml").read_text() self.assertIn("Treat `requiredChecks` as downstream exact-head gates", instructions) self.assertIn("do not run them in this read-only", instructions) self.assertIn("not-yet-run status as unresolved", instructions) @@ -87,7 +87,7 @@ def test_policy_capture_urls_are_commit_pinned(self): sha = "a" * 40 policy, invariants = pinned_policy_urls(sha) self.assertIn(f"/{sha}/support-policy.json", policy) - self.assertIn(f"/{sha}/maintenance/policy-invariants.json", invariants) + self.assertIn(f"/{sha}/autorelease/policy-invariants.json", invariants) with self.assertRaises(Exception): pinned_policy_urls("main") @@ -219,7 +219,7 @@ 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() protected_workflow = (root / ".github/workflows/protected-controls.yml").read_text() - consumer = (root / ".github/workflows/maintenance-consumer.yml").read_text() + consumer = (root / ".github/workflows/autorelease-consumer.yml").read_text() dispatcher = (root / "scripts/dispatch-pr-checks").read_text() self.assertIn("workflow_dispatch:", ci) self.assertIn("workflow_dispatch:", protected_workflow) From 1a9ab659219a577d03a2ed3a9a0537d58f2c1ec1 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Sun, 2 Aug 2026 13:09:07 +0300 Subject: [PATCH 2/4] fix: fall back to the pre-rename php-bin invariants path pinned_policy_urls returned only autorelease/policy-invariants.json, but php-bin main keeps maintenance/policy-invariants.json until its own autorelease change merges. Because this repository must merge first, every policy capture in that window would have failed before compare or the operator pause could run. fetch_first_url tries the current path and falls through to the older one only on a 404, so a transport failure still raises instead of silently reaching for the older document. The capture record already reports which URL supplied the bytes. Remove the fallback once php-bin main has landed. AUTORELEASE.md said the consumer compares only the policy digest and incomplete-event state; it also compares the invariants digest, the php-bin policy commit, and the maintained branches. --- AUTORELEASE.md | 12 +++++++----- autorelease/consumer.py | 35 ++++++++++++++++++++++++++++++---- test/test_autorelease.py | 41 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/AUTORELEASE.md b/AUTORELEASE.md index b30b9c2..aa699dc 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -5,11 +5,13 @@ prepares bounded repository work, and records the exact-commit readiness that `php-bin` requires before it may publish a new branch. The scheduled `php-bin policy consumer` captures the accepted public -`support-policy.json` and compares only its digest and incomplete-event state -with `support-snapshot.json`. It does not fetch or classify upstream PHP -lifecycle data. When the exact policy changes, the repository-scoped pinned -Codex Action produces an evidence-bound plan. Any implementation runs offline, -without a GitHub write credential, and only against admitted paths. +`support-policy.json` and compares it with `support-snapshot.json`: the policy +digest, the invariants digest, the php-bin policy commit, the maintained +branches, and any locally incomplete event. It does not fetch or classify +upstream PHP lifecycle data. When the exact policy changes, the +repository-scoped pinned Codex Action produces an evidence-bound plan. Any +implementation runs offline, without a GitHub write credential, and only +against admitted paths. ```mermaid flowchart TD diff --git a/autorelease/consumer.py b/autorelease/consumer.py index b14f99d..d694cec 100755 --- a/autorelease/consumer.py +++ b/autorelease/consumer.py @@ -38,6 +38,10 @@ class ConsumerError(RuntimeError): pass +class CaptureAbsent(ConsumerError): + """The capture URL resolved but the document is not published at that path.""" + + 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) @@ -102,17 +106,40 @@ def fetch_url(url: str, output: pathlib.Path) -> dict[str, Any]: "digest": digest(body), "bodyPath": output.name, } + except urllib.error.HTTPError as error: + if error.code == 404: + raise CaptureAbsent(f"policy capture path is not published: {url}") from error + last_error = error except (OSError, urllib.error.URLError, json.JSONDecodeError, ConsumerError) as error: last_error = error raise ConsumerError(f"policy capture failed after bounded retries: {type(last_error).__name__}") -def pinned_policy_urls(commit_sha: str) -> tuple[str, str]: +def fetch_first_url(urls: tuple[str, ...], output: pathlib.Path) -> dict[str, Any]: + """Capture the first published path, recording which one supplied the bytes. + + php-bin main keeps the pre-rename `maintenance/` path until its own + autorelease change merges. Only a 404 falls through, so a transport failure + still raises instead of silently reaching for the older document. Drop every + path but the first once php-bin main has landed. + """ + for url in urls[:-1]: + try: + return fetch_url(url, output) + except CaptureAbsent: + continue + return fetch_url(urls[-1], output) + + +def pinned_policy_urls(commit_sha: str) -> tuple[str, tuple[str, ...]]: if not re.fullmatch(r"[0-9a-f]{40}", commit_sha): raise ConsumerError("php-bin main state has no exact commit") return ( f"{RAW_ROOT}/{commit_sha}/support-policy.json", - f"{RAW_ROOT}/{commit_sha}/autorelease/policy-invariants.json", + ( + f"{RAW_ROOT}/{commit_sha}/autorelease/policy-invariants.json", + f"{RAW_ROOT}/{commit_sha}/maintenance/policy-invariants.json", + ), ) @@ -130,7 +157,7 @@ def fetch_policy_set( if not isinstance(selected, list) or len(selected) != 1: raise ConsumerError("php-bin policy commit selector is empty or ambiguous") commit_sha = selected[0].get("sha", "") - policy_url, invariants_url = pinned_policy_urls(commit_sha) + policy_url, invariants_urls = pinned_policy_urls(commit_sha) commit_capture = { "captureId": "php_bin_state", **fetch_url(f"{POLICY_COMMIT_ROOT}/{commit_sha}", commit_output), @@ -139,7 +166,7 @@ def fetch_policy_set( selector_capture, commit_capture, {"captureId": "support_policy", **fetch_url(policy_url, policy_output)}, - {"captureId": "policy_invariants", **fetch_url(invariants_url, invariants_output)}, + {"captureId": "policy_invariants", **fetch_first_url(invariants_urls, invariants_output)}, ] diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 93d4f2a..4dd8095 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -3,9 +3,20 @@ import tempfile import unittest import json +from unittest import mock +from autorelease import consumer from autorelease.admission import AdmissionError, admit, digest_file, protected, verify_merge -from autorelease.consumer import compare, digest, pinned_policy_urls, readiness, write +from autorelease.consumer import ( + CaptureAbsent, + ConsumerError, + compare, + digest, + fetch_first_url, + pinned_policy_urls, + readiness, + write, +) class AutoreleaseConsumerTests(unittest.TestCase): @@ -63,7 +74,7 @@ def test_readiness_requires_exact_commits_and_digests(self): def test_protected_controls_are_not_admissible(self): self.assertTrue(protected(".github/codex-action-contract.json")) - self.assertTrue(protected(".github/workflows/autorelease.yml")) + self.assertTrue(protected(".github/workflows/autorelease-consumer.yml")) self.assertTrue(protected("autorelease/admission.py")) self.assertTrue(protected("scripts/validate-codex-action-inputs")) self.assertTrue(protected("autorelease-events/new-patch.json")) @@ -87,10 +98,34 @@ def test_policy_capture_urls_are_commit_pinned(self): sha = "a" * 40 policy, invariants = pinned_policy_urls(sha) self.assertIn(f"/{sha}/support-policy.json", policy) - self.assertIn(f"/{sha}/autorelease/policy-invariants.json", invariants) + self.assertEqual( + [ + f"/{sha}/autorelease/policy-invariants.json", + f"/{sha}/maintenance/policy-invariants.json", + ], + [url.split("/php-bin")[-1] for url in invariants], + ) with self.assertRaises(Exception): pinned_policy_urls("main") + def test_policy_invariants_capture_prefers_the_current_path(self): + urls = ("https://example.invalid/new.json", "https://example.invalid/old.json") + output = pathlib.Path("unused.json") + + with mock.patch.object(consumer, "fetch_url", return_value={"url": urls[0]}) as fetch: + self.assertEqual(urls[0], fetch_first_url(urls, output)["url"]) + fetch.assert_called_once_with(urls[0], output) + + absent = [CaptureAbsent("absent"), {"url": urls[1]}] + with mock.patch.object(consumer, "fetch_url", side_effect=absent) as fetch: + self.assertEqual(urls[1], fetch_first_url(urls, output)["url"]) + self.assertEqual(2, fetch.call_count) + + # A transport failure must surface rather than reach for the older path. + with mock.patch.object(consumer, "fetch_url", side_effect=ConsumerError("timeout")): + with self.assertRaises(ConsumerError): + fetch_first_url(urls, output) + def test_merge_gate_binds_single_commit_diff_and_preconditions(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) From 7cd8b658ebc8e6be9c7add171dfe253cf8f2365b Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Sun, 2 Aug 2026 13:36:18 +0300 Subject: [PATCH 3/4] fix(ci): bind agent-supplied action key through env before shell use The action key comes out of a model-authored plan. Admission already rejects a malformed one, but the value crossed into $GITHUB_OUTPUT and four run scripts as a raw ${{ }} expansion, which GitHub substitutes into script text ahead of the shell. Its alphabet is now asserted at that boundary and every later use reads it from an env map. The base SHA and PR number interpolations move to the same env pattern so the workflow has one idiom for values entering a script. --- .github/workflows/autorelease-consumer.yml | 40 +++++++++++++++------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index fddd8e8..9f56135 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -215,10 +215,12 @@ jobs: allow-bot-users: github-actions[bot] codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal admitted diff + env: + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | ./scripts/seal-autorelease-patch \ --repo . \ - --base "${{ needs.investigate.outputs.base_sha }}" \ + --base "$BASE_SHA" \ --plan autorelease-run/implementation-plan.json \ --result autorelease-run/implementation-result.json \ --contract autorelease-run/implementation-contract.json \ @@ -272,6 +274,8 @@ jobs: fi - name: Create reproducible validated commit bundle if: steps.checks.outputs.passed == 'true' + env: + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | export GIT_AUTHOR_NAME=autorelease-validator export GIT_AUTHOR_EMAIL=autorelease@invalid @@ -282,7 +286,7 @@ jobs: git commit -m "chore: apply admitted autorelease patch" jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" \ '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json - git bundle create autorelease-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" + git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: steps.checks.outputs.passed == 'true' with: @@ -367,11 +371,13 @@ jobs: allow-bot-users: github-actions[bot] codex-args: '["--strict-config","--ephemeral","--output-schema","schemas/agent-completion-assessment.schema.json"]' - name: Seal combined repaired diff + env: + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | mv autorelease-run/repair-plan.json autorelease-run/implementation-plan.json ./scripts/seal-autorelease-patch \ --repo . \ - --base "${{ needs.investigate.outputs.base_sha }}" \ + --base "$BASE_SHA" \ --plan autorelease-run/implementation-plan.json \ --result autorelease-run/repair-result.json \ --contract autorelease-run/repair-contract.json \ @@ -413,6 +419,8 @@ jobs: cp autorelease-run/sealed-repair/sealed.patch autorelease-run/sealed/sealed.patch cp autorelease-run/sealed-repair/patch-manifest.json autorelease-run/sealed/patch-manifest.json - name: Create repaired validated commit bundle + env: + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} run: | export GIT_AUTHOR_NAME=autorelease-validator export GIT_AUTHOR_EMAIL=autorelease@invalid @@ -423,7 +431,7 @@ jobs: git commit -m "chore: apply admitted autorelease repair" jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" \ '{headSha:$headSha,tree:$tree,checks:{"Plugin contract":"success"}}' > autorelease-run/validation.json - git bundle create autorelease-run/validated.bundle HEAD "^${{ needs.investigate.outputs.base_sha }}" + git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mise-validated-autorelease-patch-${{ github.run_id }} @@ -463,6 +471,10 @@ jobs: git checkout --detach "$validated" test "$(git rev-parse HEAD)" = "$validated" action_key="$(jq -r .actionKey autorelease-run/implementation-plan.json)" + # The plan is model-authored. Admission already rejects a malformed + # action key, but this value reaches $GITHUB_OUTPUT and later shell + # steps, so its alphabet is re-asserted at the boundary. + [[ "$action_key" =~ ^[A-Za-z0-9._:-]+$ ]] branch="autorelease/$(printf '%s' "$action_key" | tr ':/' '--')" gh auth setup-git git push origin "HEAD:refs/heads/$branch" @@ -477,14 +489,15 @@ jobs: - name: Enforce checks and exact validated SHA env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.patch.outputs.number }} run: | ./scripts/dispatch-pr-checks \ - --pr "${{ steps.patch.outputs.number }}" \ + --pr "$PR_NUMBER" \ --check "Plugin contract" \ --output autorelease-run/pr-checks.json jq -e '[.[] | select(.name=="Plugin contract") | .bucket] == ["pass"]' autorelease-run/pr-checks.json expected="$(jq -r .headSha autorelease-run/validation.json)" - actual="$(gh pr view "${{ steps.patch.outputs.number }}" --json headRefOid --jq .headRefOid)" + actual="$(gh pr view "$PR_NUMBER" --json headRefOid --jq .headRefOid)" test "$actual" = "$expected" test "$(git rev-parse HEAD)" = "$expected" git fetch origin main @@ -535,25 +548,28 @@ jobs: - name: Merge exact admitted patch env: GH_TOKEN: ${{ github.token }} - run: gh pr merge "${{ steps.patch.outputs.number }}" --squash --delete-branch + PR_NUMBER: ${{ steps.patch.outputs.number }} + run: gh pr merge "$PR_NUMBER" --squash --delete-branch - name: Create readiness record at the merged implementation commit id: readiness env: GH_TOKEN: ${{ github.token }} + ACTION_KEY: ${{ steps.patch.outputs.action_key }} + PR_NUMBER: ${{ steps.patch.outputs.number }} run: | - merged="$(gh pr view "${{ steps.patch.outputs.number }}" --json mergeCommit --jq .mergeCommit.oid)" + merged="$(gh pr view "$PR_NUMBER" --json mergeCommit --jq .mergeCommit.oid)" git fetch origin main test "$(git rev-parse origin/main)" = "$merged" test "$(git rev-parse "$merged^{tree}")" = "$(jq -r .tree autorelease-run/validation.json)" git checkout -B autorelease/readiness-${{ github.run_id }} origin/main base="$(git rev-parse HEAD)" mkdir -p readiness - filename="$(printf '%s' "${{ steps.patch.outputs.action_key }}" | tr ':/' '--').json" + filename="$(printf '%s' "$ACTION_KEY" | tr ':/' '--').json" mapfile -t digests < <(jq -r '.captures[].digest' autorelease-run/policy-capture.json) args=() for digest in "${digests[@]}"; do args+=(--evidence-digest "$digest"); done ./scripts/consume-php-policy readiness \ - --action-key "${{ steps.patch.outputs.action_key }}" \ + --action-key "$ACTION_KEY" \ --php-bin-commit "$(jq -r .preconditions.phpBinPolicyCommit autorelease-run/implementation-plan.json)" \ --policy-digest "$(jq -r .preconditions.supportPolicyDigest autorelease-run/implementation-plan.json)" \ --policy-invariants-digest "$(jq -r .preconditions.policyInvariantsDigest autorelease-run/implementation-plan.json)" \ @@ -562,11 +578,11 @@ jobs: --output "readiness/$filename" git add "readiness/$filename" git -c user.name=autorelease-readiness -c user.email=autorelease@invalid \ - commit -m "chore: record ${{ steps.patch.outputs.action_key }} readiness" + commit -m "chore: record $ACTION_KEY readiness" gh auth setup-git git push origin HEAD url="$(gh pr create --base main --head "autorelease/readiness-${{ github.run_id }}" \ - --title "chore: record ${{ steps.patch.outputs.action_key }} readiness" \ + --title "chore: record $ACTION_KEY readiness" \ --body "Deterministic readiness record for the exact merged implementation commit.")" echo "number=${url##*/}" >> "$GITHUB_OUTPUT" echo "base_sha=$base" >> "$GITHUB_OUTPUT" From b6b7e742ac04ed5ed3d3167d433f233d4650ff26 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Sun, 2 Aug 2026 13:50:56 +0300 Subject: [PATCH 4/4] fix(ci): distinguish an absent operator control from an API failure The merge-admission step probed for .github/autorelease-operator.json and fell back to the pre-rename filename on any non-zero exit, so a transient API error selected the legacy path. One directory listing now decides it, and anything other than a definite answer fails the step. Also tightens the pinned_policy_urls rejection test to ConsumerError, matching the idiom the rest of the file already uses. --- .github/workflows/autorelease-consumer.yml | 15 +++++++++++---- test/test_autorelease.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/autorelease-consumer.yml b/.github/workflows/autorelease-consumer.yml index 9f56135..0799da2 100644 --- a/.github/workflows/autorelease-consumer.yml +++ b/.github/workflows/autorelease-consumer.yml @@ -513,10 +513,17 @@ jobs: admitted_operator_commit="$(jq -r .preconditions.phpBinOperatorCommit autorelease-run/implementation-plan.json)" admitted_operator_state="$(jq -r .preconditions.operatorState autorelease-run/implementation-plan.json)" current_operator_commit="$(gh api repos/Bigpixelrocket/php-bin/commits/main --jq .sha)" - # Same pre-rename fallback as the operator bind step above. - operator_path=".github/autorelease-operator.json" - gh api "repos/Bigpixelrocket/php-bin/contents/$operator_path?ref=$current_operator_commit" >/dev/null 2>&1 \ - || operator_path=".github/maintenance-operator.json" + # Same pre-rename fallback as the operator bind step above, decided by + # one directory listing rather than a probe. A probe's non-zero exit + # cannot separate "the file is absent" from "the API call failed", and + # a transient error must not select the legacy path. + operator_names="$(gh api "repos/Bigpixelrocket/php-bin/contents/.github?ref=$current_operator_commit" --jq '.[].name')" + if printf '%s\n' "$operator_names" | grep -qx 'autorelease-operator.json'; then + operator_path=".github/autorelease-operator.json" + else + printf '%s\n' "$operator_names" | grep -qx 'maintenance-operator.json' + operator_path=".github/maintenance-operator.json" + fi gh api "repos/Bigpixelrocket/php-bin/contents/$operator_path?ref=$current_operator_commit" \ --jq .content | base64 --decode > autorelease-run/current-operator.json jq -n \ diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 4dd8095..671a0fb 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -105,7 +105,7 @@ def test_policy_capture_urls_are_commit_pinned(self): ], [url.split("/php-bin")[-1] for url in invariants], ) - with self.assertRaises(Exception): + with self.assertRaises(ConsumerError): pinned_policy_urls("main") def test_policy_invariants_capture_prefers_the_current_path(self):