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 62% rename from .github/workflows/maintenance-consumer.yml rename to .github/workflows/autorelease-consumer.yml index 9769649..0799da2 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 @@ -209,19 +215,21 @@ 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-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 + --base "$BASE_SHA" \ + --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 +251,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 @@ -266,30 +274,32 @@ 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=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 "^$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 +320,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 +337,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 +361,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 @@ -361,20 +371,22 @@ 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 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 + --base "$BASE_SHA" \ + --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 +406,36 @@ 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 + env: + BASE_SHA: ${{ needs.investigate.outputs.base_sha }} 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 "^$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 +459,29 @@ 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)" + # 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" 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" @@ -471,31 +489,43 @@ 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 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)" - actual="$(gh pr view "${{ steps.patch.outputs.number }}" --json headRefOid --jq .headRefOid)" + --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 "$PR_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, 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 \ --arg misePhpHead "$admitted_mise" \ --arg phpBinPolicyCommit "$admitted_php" \ @@ -504,59 +534,62 @@ 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 }} - 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 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) + 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 }}" \ - --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)" \ + --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)" \ --mise-commit "$merged" \ "${args[@]}" \ --output "readiness/$filename" git add "readiness/$filename" - git -c user.name=maintenance-readiness -c user.email=maintenance@invalid \ - commit -m "chore: record ${{ steps.patch.outputs.action_key }} readiness" + git -c user.name=autorelease-readiness -c user.email=autorelease@invalid \ + commit -m "chore: record $ACTION_KEY readiness" gh auth setup-git git push origin HEAD - url="$(gh pr create --base main --head "maintenance/readiness-${{ github.run_id }}" \ - --title "chore: record ${{ steps.patch.outputs.action_key }} readiness" \ + url="$(gh pr create --base main --head "autorelease/readiness-${{ github.run_id }}" \ + --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" @@ -570,8 +603,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..aa699dc --- /dev/null +++ b/AUTORELEASE.md @@ -0,0 +1,76 @@ +# 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 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 + 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 90% rename from maintenance/consumer.py rename to autorelease/consumer.py index 3edd756..d694cec 100755 --- a/maintenance/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) @@ -79,7 +83,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 @@ -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}/maintenance/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)}, ] @@ -350,7 +377,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 85% rename from test/test_maintenance.py rename to test/test_autorelease.py index c3a6e26..671a0fb 100644 --- a/test/test_maintenance.py +++ b/test/test_autorelease.py @@ -3,12 +3,23 @@ import tempfile import unittest import json +from unittest import mock -from maintenance.admission import AdmissionError, admit, digest_file, protected, verify_merge -from maintenance.consumer import compare, digest, pinned_policy_urls, readiness, write +from autorelease import consumer +from autorelease.admission import AdmissionError, admit, digest_file, protected, verify_merge +from autorelease.consumer import ( + CaptureAbsent, + ConsumerError, + compare, + digest, + fetch_first_url, + 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 +74,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-consumer.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,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}/maintenance/policy-invariants.json", invariants) - with self.assertRaises(Exception): + 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(ConsumerError): 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) @@ -219,7 +254,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)