diff --git a/.github/workflows/autorelease-implement.yml b/.github/workflows/autorelease-implement.yml index 3b23292..973b649 100644 --- a/.github/workflows/autorelease-implement.yml +++ b/.github/workflows/autorelease-implement.yml @@ -54,6 +54,7 @@ jobs: action_key: ${{ steps.admitted.outputs.action_key }} action: ${{ steps.admitted.outputs.action }} version: ${{ steps.admitted.outputs.version }} + already_applied: ${{ steps.sealed.outputs.already_applied }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -97,6 +98,7 @@ jobs: --manifest autorelease-run/evidence/evidence-manifest.json \ --repo . - name: Seal exact deterministic diff + id: sealed env: BASE_SHA: ${{ needs.preflight.outputs.base_sha }} run: | @@ -105,6 +107,10 @@ jobs: --base "$BASE_SHA" \ --plan autorelease-run/autorelease-plan.json \ --output autorelease-run/sealed + # A resumed lifecycle whose edit already merged seals an empty patch. Every + # later job then validates, builds, and records the admitted base itself, and + # nothing is merged except the readiness record. + echo "already_applied=$(jq -r '.alreadyApplied == true' autorelease-run/sealed/patch-manifest.json)" >> "$GITHUB_OUTPUT" - name: Retain sealed patch uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -134,7 +140,13 @@ jobs: - name: Apply exact sealed bytes run: | test "$(./autorelease/control.py digest autorelease-run/sealed/sealed.patch)" = "$(jq -r .patchDigest autorelease-run/sealed/patch-manifest.json)" - git apply --index autorelease-run/sealed/sealed.patch + if [[ "$(jq -r '.alreadyApplied == true' autorelease-run/sealed/patch-manifest.json)" == "true" ]]; then + # The admitted edit is already on the base, so the checks run on the base. + test "$(jq -c .files autorelease-run/sealed/patch-manifest.json)" = "[]" + test ! -s autorelease-run/sealed/sealed.patch + else + git apply --index autorelease-run/sealed/sealed.patch + fi - name: Run authoritative checks and retain failure logs id: run-checks run: | @@ -152,15 +164,23 @@ jobs: env: BASE_SHA: ${{ needs.preflight.outputs.base_sha }} run: | - 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 autorelease patch" - jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse HEAD^{tree})" '{headSha:$headSha,tree:$tree,checks:{"Script checks":"success"}}' > autorelease-run/validation.json - git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" + if [[ "$(jq -r '.alreadyApplied == true' autorelease-run/sealed/patch-manifest.json)" == "true" ]]; then + # Nothing was applied, so the validated commit is the admitted base, which + # every later job checks out; there is no commit to bundle. + test "$(git rev-parse HEAD)" = "$BASE_SHA" + # The verdict is bound to HEAD, so the tested tree must equal it exactly. + git diff --quiet HEAD -- + else + 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 autorelease patch" + git bundle create autorelease-run/validated.bundle HEAD "^$BASE_SHA" + fi + jq -n --arg headSha "$(git rev-parse HEAD)" --arg tree "$(git rev-parse "HEAD^{tree}")" '{headSha:$headSha,tree:$tree,checks:{"Script checks":"success"}}' > autorelease-run/validation.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: steps.run-checks.outputs.status == 'passed' with: @@ -215,10 +235,17 @@ jobs: name: validated-autorelease-patch-${{ github.run_id }} path: autorelease-run - name: Restore exact validated commit + env: + BASE_SHA: ${{ needs.preflight.outputs.base_sha }} run: | validated="$(jq -r .headSha autorelease-run/validation.json)" - git fetch autorelease-run/validated.bundle HEAD - git checkout --detach "$validated" + if [[ "$(jq -r '.alreadyApplied == true' autorelease-run/sealed/patch-manifest.json)" == "true" ]]; then + # An already-applied edit validated the admitted base, which is checked out. + test "$validated" = "$BASE_SHA" + else + git fetch autorelease-run/validated.bundle HEAD + git checkout --detach "$validated" + fi test "$(git rev-parse HEAD)" = "$validated" test "$(uname -s)" = Darwin && test "$(uname -m)" = arm64 [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] @@ -283,13 +310,21 @@ jobs: name: validated-autorelease-patch-${{ github.run_id }} path: autorelease-run - name: Restore exact validated commit + env: + BASE_SHA: ${{ needs.preflight.outputs.base_sha }} run: | validated="$(jq -r .headSha autorelease-run/validation.json)" - git fetch autorelease-run/validated.bundle HEAD - git checkout --detach "$validated" + if [[ "$(jq -r '.alreadyApplied == true' autorelease-run/sealed/patch-manifest.json)" == "true" ]]; then + # An already-applied edit validated the admitted base, which is checked out. + test "$validated" = "$BASE_SHA" + else + git fetch autorelease-run/validated.bundle HEAD + git checkout --detach "$validated" + fi test "$(git rev-parse HEAD)" = "$validated" - name: Create or reuse automation PR id: pr + if: needs.implement.outputs.already_applied != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -315,6 +350,7 @@ jobs: fi echo "number=$existing" >> "$GITHUB_OUTPUT" - name: Wait for required checks on exact head + if: needs.implement.outputs.already_applied != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -324,6 +360,7 @@ jobs: --output autorelease-run/pr-checks.json ./scripts/assert-admission-checks --checks autorelease-run/pr-checks.json - name: Re-verify exact SHA, sealed tree, and preconditions + if: needs.implement.outputs.already_applied != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -350,6 +387,7 @@ jobs: --current autorelease-run/current.json - name: Merge admitted exact commit id: merged + if: needs.implement.outputs.already_applied != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -358,14 +396,44 @@ jobs: gh pr merge "${{ steps.pr.outputs.number }}" --squash --delete-branch \ --match-head-commit "$(jq -r .headSha autorelease-run/validation.json)" echo "commit=$(gh pr view "${{ steps.pr.outputs.number }}" --json mergeCommit --jq .mergeCommit.oid)" >> "$GITHUB_OUTPUT" + # A resumed lifecycle merged its edit in an earlier run that stopped before its + # readiness record, so no PR is opened and nothing is merged. The validated commit + # must be the admitted base and still be main under the admitted policy; the + # validation job's checks ran on exactly that commit. + - name: Re-verify the already-merged edit at the validated commit + id: onmain + if: needs.implement.outputs.already_applied == 'true' + run: | + expected="$(jq -r .headSha autorelease-run/validation.json)" + test "$(git rev-parse HEAD)" = "$expected" + admitted_head="$(jq -r .preconditions.phpBinHead autorelease-run/autorelease-plan.json)" + admitted_policy="$(jq -r .preconditions.supportPolicyDigest autorelease-run/autorelease-plan.json)" + git fetch origin main + current_head="$(git rev-parse origin/main)" + current_policy="sha256:$(git show origin/main:support-policy.json | shasum -a 256 | awk '{print $1}')" + jq -n --arg phpBinHead "$admitted_head" --arg supportPolicyDigest "$admitted_policy" \ + '{phpBinHead:$phpBinHead,supportPolicyDigest:$supportPolicyDigest}' > autorelease-run/preconditions.json + jq -n --arg phpBinHead "$current_head" --arg supportPolicyDigest "$current_policy" \ + '{phpBinHead:$phpBinHead,supportPolicyDigest:$supportPolicyDigest}' > autorelease-run/current.json + jq .checks autorelease-run/validation.json > autorelease-run/checks.json + ./scripts/verify-merge-admission \ + --repo . \ + --head "$expected" \ + --manifest autorelease-run/sealed/patch-manifest.json \ + --checks autorelease-run/checks.json \ + --preconditions autorelease-run/preconditions.json \ + --current autorelease-run/current.json + echo "commit=$expected" >> "$GITHUB_OUTPUT" - name: Commit deterministic php_bin_ready event record id: readiness env: GH_TOKEN: ${{ github.token }} + # Exactly one of the two steps above ran and named the commit now on main. + MERGED_COMMIT: ${{ steps.merged.outputs.commit || steps.onmain.outputs.commit }} run: | git fetch origin main - test "$(git rev-parse origin/main)" = "${{ steps.merged.outputs.commit }}" - test "$(git rev-parse "${{ steps.merged.outputs.commit }}^{tree}")" = "$(jq -r .tree autorelease-run/validation.json)" + test "$(git rev-parse origin/main)" = "$MERGED_COMMIT" + test "$(git rev-parse "$MERGED_COMMIT^{tree}")" = "$(jq -r .tree autorelease-run/validation.json)" git checkout -B "autorelease/readiness-${{ github.run_id }}" origin/main base="$(git rev-parse HEAD)" action_key="$(jq -r .actionKey autorelease-run/autorelease-plan.json)" @@ -374,7 +442,7 @@ jobs: jq -n \ --arg actionKey "$action_key" \ --arg classification "$(jq -r .action autorelease-run/autorelease-plan.json)" \ - --arg phpBinCommit "${{ steps.merged.outputs.commit }}" \ + --arg phpBinCommit "$MERGED_COMMIT" \ --arg planDigest "$(jq -r .planDigest autorelease-run/sealed/patch-manifest.json)" \ --arg policyDigest "$(./autorelease/control.py digest support-policy.json)" \ --arg policyInvariantsDigest "$(./autorelease/control.py digest autorelease/policy-invariants.json)" \ @@ -394,7 +462,7 @@ jobs: evidenceDigests:$evidenceDigests }' > "autorelease-events/$filename" jq -n \ - --arg commit "${{ steps.merged.outputs.commit }}" \ + --arg commit "$MERGED_COMMIT" \ --arg planDigest "$(jq -r .planDigest autorelease-run/sealed/patch-manifest.json)" \ '[{kind:"validated_merge",commit:$commit,planDigest:$planDigest}]' > autorelease-run/readiness-evidence.json ./scripts/autorelease-event \ @@ -410,15 +478,29 @@ jobs: record="autorelease-events/$filename" digest="sha256:$(shasum -a 256 "$record" | awk '{print $1}')" gh auth setup-git + # An earlier attempt that failed after opening its readiness PR leaves it open. + # This record replaces it, so the stale PR is closed rather than left to conflict. + # This run's own branch is never closed: a re-run then fails at the push as before. + # --repo keeps the branch deletion remote-only, as in merge-record-pr. + gh pr list --state open --author app/github-actions --limit 100 --json number,headRefName,title | + jq -r --arg title "chore: record $action_key php-bin readiness" \ + --arg own "autorelease/readiness-${{ github.run_id }}" \ + '.[] | select(.title == $title and .headRefName != $own and (.headRefName | startswith("autorelease/readiness-"))) | .number' | + while read -r stale; do + gh pr close "$stale" --repo "$GITHUB_REPOSITORY" --delete-branch \ + --comment "Superseded by the readiness record of run ${{ github.run_id }}." + done git push origin HEAD url="$(gh pr create --base main --head "autorelease/readiness-${{ github.run_id }}" \ --title "chore: record $action_key php-bin readiness" \ --body "Deterministic event state for the exact merged implementation commit.")" - echo "number=${url##*/}" >> "$GITHUB_OUTPUT" - echo "base_sha=$base" >> "$GITHUB_OUTPUT" - echo "head_sha=$head" >> "$GITHUB_OUTPUT" - echo "record=$record" >> "$GITHUB_OUTPUT" - echo "digest=$digest" >> "$GITHUB_OUTPUT" + { + echo "number=${url##*/}" + echo "base_sha=$base" + echo "head_sha=$head" + echo "record=$record" + echo "digest=$digest" + } >> "$GITHUB_OUTPUT" - name: Validate and merge php-bin readiness record env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/autorelease-watch.yml b/.github/workflows/autorelease-watch.yml index 41f11cc..ea4d4a2 100644 --- a/.github/workflows/autorelease-watch.yml +++ b/.github/workflows/autorelease-watch.yml @@ -95,15 +95,18 @@ jobs: --mise-php-head "$(jq -r .misePhpHead autorelease-run/preconditions.json)" \ --policy-digest "$(./autorelease/control.py digest support-policy.json)" \ --completed-actions autorelease-run/completed-actions.json \ + --events autorelease-events \ --output autorelease-run/admission.json - name: Expose admitted plan id: plan if: steps.decision.outputs.classify == 'true' run: | - echo "action_key=$(jq -r .actionKey autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" - echo "edits_required=$(jq -r .editsRequired autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" - echo "base_sha=$(jq -r .preconditions.phpBinHead autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" - echo "action=$(jq -r .action autorelease-run/autorelease-plan.json)" >> "$GITHUB_OUTPUT" + { + echo "action_key=$(jq -r .actionKey autorelease-run/autorelease-plan.json)" + echo "edits_required=$(jq -r .editsRequired autorelease-run/autorelease-plan.json)" + echo "base_sha=$(jq -r .preconditions.phpBinHead autorelease-run/autorelease-plan.json)" + echo "action=$(jq -r .action autorelease-run/autorelease-plan.json)" + } >> "$GITHUB_OUTPUT" - name: Retain evidence and admitted plan if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/AUTORELEASE.md b/AUTORELEASE.md index f2f4112..b5c9493 100644 --- a/AUTORELEASE.md +++ b/AUTORELEASE.md @@ -66,12 +66,19 @@ stops at the first that applies: later patch the aggregate feed names. The plan cites exactly that branch feed value. A maintained branch with no shipped release is never a `new_patch`: its first release belongs to `new_branch`, which waits for - both readiness records, so it produces `needs_human` instead. Admission - rejects that patch independently. New patches never read the + both readiness records. When the accepted policy was written by that + branch's own `new_branch` edit and no record exists, the lifecycle + resumes (see [Unattended lifecycle](#unattended-lifecycle)), waiting while + a later patch supersedes the first release; otherwise it produces + `needs_human`. Admission rejects that patch independently. New + patches never read the supported-versions page, so they go before lifecycle work and keep shipping while that page cannot be read. 4. **Lifecycle.** The captured supported-versions page is parsed by a reviewed - reader that accepts exactly one table shape. A supported branch the policy + reader that accepts exactly one table shape. A retirement whose policy + edit already merged without a record resumes first, and no new lifecycle + edit starts while a new branch's merged edit still waits to resume, since + that edit would rewrite the policy's action key. A supported branch the policy does not maintain is a `new_branch` once the aggregate release feed names its first stable release. A maintained branch whose row is marked end of life is a `branch_eol` keyed on its security support end date. php.net @@ -331,6 +338,24 @@ new builds and publication for the branch and delists it from release already published stays immutable, and an exact version such as `8.2.32` installs exactly as before, indefinitely. +A lifecycle run can fail after its edit merged but before its `php_bin_ready` +record lands, for example in the build, the merge, or the record PR. A rerun +cannot help, because its admitted base is no longer main. The next watcher run +resumes the action instead: when the accepted policy carries the action's own +key (`new_branch:` with the branch maintained and its module list +present, or `branch_eol::` with the branch removed) and no event +record exists for it, the classifier emits the same lifecycle action with no +allowed paths. Admission re-checks all of that against the checked-out base and +rejects anything else, and a new branch must not have shipped. The +implementation run then finds the edit already present, seals an explicit +empty patch (`alreadyApplied`), validates and, for a new branch, builds main's +exact commit, skips the lifecycle PR and merge, and files the readiness record +for exactly that commit while main is still it. A failed validation or build +reports to the owner issue for the action key, as on the first attempt, and the +next watcher run retries. A readiness PR an earlier attempt left open for the +same key is closed when the next attempt opens its own, so a retry does not +leave the earlier attempt's PR behind. + Unattended mutation is controlled by `.github/autorelease-operator.json`. Set `unattendedMutation` to `paused` in a reviewed protected-path PR to stop implementation, merge, and release while diff --git a/autorelease/_admission.py b/autorelease/_admission.py index a690bff..6053e6c 100644 --- a/autorelease/_admission.py +++ b/autorelease/_admission.py @@ -163,13 +163,25 @@ def validate_patch_extends_shipped_branch( match = re.fullmatch(r"(\d+\.\d+)\.\d+", str(release_intent.get("version", ""))) require(bool(match), f"stable release version is invalid: {release_intent.get('version')}") branch = match.group(1) + require( + branch_has_shipped(manifest_path, branch, completed_actions), + f"new_patch would be the first PHP {branch} release; only new_branch may publish it", + ) + + +def branch_has_shipped(manifest_path: pathlib.Path, branch: str, completed_actions: set[str]) -> bool: + """Tell whether a PHP branch already has a release. + + A branch has shipped when the captured php-bin releases hold a published release + on it, or a completed event record names a release on it. + """ _capture, body = load_capture(manifest_path, "php_bin_releases") try: releases = json.loads(body) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise ControlError("php-bin releases capture is not valid JSON") from error require(isinstance(releases, list), "php-bin releases capture is not a release array") - shipped = any( + return any( isinstance(release, dict) and not release.get("draft") and not release.get("prerelease") @@ -181,7 +193,6 @@ def validate_patch_extends_shipped_branch( or bool(re.fullmatch(rf"(?:new_patch|recipe_rebuild):{re.escape(branch)}\.\d+(?::\d+)?", key)) for key in completed_actions ) - require(shipped, f"new_patch would be the first PHP {branch} release; only new_branch may publish it") def validate_recipe_rebuild_evidence( @@ -296,6 +307,85 @@ def validate_support_policy(root: pathlib.Path = ROOT) -> dict[str, Any]: } +LIFECYCLE_ACTION_KEY_RE = re.compile(r"new_branch:(\d+\.\d+)|branch_eol:(\d+\.\d+):\d{4}-\d{2}-\d{2}") + + +def is_lifecycle_resume(plan: dict[str, Any]) -> bool: + """Tell whether a plan resumes a lifecycle edit that already merged. + + A lifecycle plan that requires the implementation run but allows no path can change + nothing: its edit is already on the base, and only the clean validation, a new + branch's real build, and the `php_bin_ready` record are still owed. Admission, the + implementation, and sealing each re-check that the edit is really there + (`validate_lifecycle_on_base`) before anything runs on it. + """ + allowed = plan.get("allowedPaths") + return ( + plan.get("action") in {"new_branch", "branch_eol"} + and plan.get("editsRequired") is True + and isinstance(allowed, dict) + and not any(allowed.values()) + ) + + +def recorded_action_keys(directory: pathlib.Path) -> set[str]: + """Return the action key of every durable event record in `directory`, failing closed.""" + require(directory.is_dir(), f"events directory is missing: {directory}") + keys = set() + for path in sorted(directory.glob("*.json")): + record = load_json(path) + key = record.get("actionKey") if isinstance(record, dict) else None + require( + isinstance(key, str) and bool(ACTION_KEY_RE.fullmatch(key)), + f"event record carries no valid action key: {path.name}", + ) + keys.add(key) + return keys + + +def validate_lifecycle_on_base( + repo: pathlib.Path, + plan: dict[str, Any], + recorded_keys: set[str] | None, +) -> str: + """Require a resumed lifecycle edit to be fully present in `repo`, and return its branch. + + `repo` is a clean tree of the plan's base. The deterministic edit binds the policy + it writes to its own action key, so an accepted policy carrying the plan's key is + that edit's output. A `new_branch` must then maintain the branch and carry its + module list, which the edit never overwrites once present, so a list corrected by + reviewed pull request still counts. A `branch_eol` must no longer maintain the + branch. The policy's evidence digests and acceptance time belong to the capture + that first admitted the edit and are only validated for shape here. + + No event record may exist for the key: an incomplete one resumes through its own + next transition instead, and a complete one means the action already finished. + `recorded_keys` is None when the records could not be read, which rejects. + """ + key = str(plan.get("actionKey", "")) + match = LIFECYCLE_ACTION_KEY_RE.fullmatch(key) + require( + bool(match) and key.startswith(f"{plan.get('action')}:"), + f"lifecycle action key is invalid: {key}", + ) + branch = match.group(1) or match.group(2) + maintained = validate_support_policy(repo)["maintainedBranches"] + policy = load_json(repo / "support-policy.json") + require(policy.get("actionKey") == key, f"the accepted support policy was not written by {key}") + if plan.get("action") == "new_branch": + require(branch in maintained, f"the accepted support policy does not maintain PHP {branch}") + modules = repo / f"expected-modules/{branch}.txt" + require( + modules.is_file() and not modules.is_symlink(), + f"the module list of PHP {branch} is missing", + ) + else: + require(branch not in maintained, f"the accepted support policy still maintains PHP {branch}") + require(recorded_keys is not None, f"the event records needed to resume {key} were not supplied") + require(key not in recorded_keys, f"an event record for {key} already exists") + return branch + + PLAN_FIELDS = frozenset( { "schemaVersion", @@ -493,6 +583,8 @@ def validate_plan( policy_digest: str | None = None, completed_actions: set[str] | None = None, pending_rebuild: str | None = None, + repo: pathlib.Path | None = None, + recorded_keys: set[str] | None = None, ) -> dict[str, Any]: """Admit one classified plan, or reject it. @@ -502,13 +594,31 @@ def validate_plan( against, and what it asks for. A later gate reads values the earlier one proved, so none of them is safe to reorder. A `new_patch` must finally extend a branch that already shipped, which needs the completed records the watcher supplied. + + A lifecycle resume (`is_lifecycle_resume`) is also checked against `repo`, the + checked-out base, and `recorded_keys`, the action keys of its event records: the + policy there must be the one the plan was classified against and already hold the + lifecycle edit, no record may exist for the key, and a resumed new branch must not + have shipped. Without both inputs a resume is rejected. """ action_key = _validate_plan_shape(plan, manifest_path, completed_actions, pending_rebuild) - _validate_plan_preconditions(plan, repo_heads, policy_digest) + declared = _validate_plan_preconditions(plan, repo_heads, policy_digest) _validate_plan_actions(plan, manifest_path) validate_patch_extends_shipped_branch( plan.get("action", ""), plan.get("releaseIntent"), manifest_path, completed_actions or set() ) + if is_lifecycle_resume(plan): + require(repo is not None, "a lifecycle resume is admitted only against the checked-out base") + require( + sha256_file(repo / "support-policy.json") == declared.get("supportPolicyDigest"), + "the checked-out support policy is not the one the plan was classified against", + ) + branch = validate_lifecycle_on_base(repo, plan, recorded_keys) + require( + plan.get("action") != "new_branch" + or not branch_has_shipped(manifest_path, branch, completed_actions or set()), + f"PHP {branch} already shipped, so its new_branch lifecycle cannot resume", + ) return { "admitted": True, "admittedAt": utc_now(), @@ -574,11 +684,19 @@ def seal_patch( to the plan's own evidence digests and action key. The sealed patch and its file digests are what clean validation applies and what the exact-SHA merge gate compares, so nothing written after sealing can reach main. + + The one legal empty patch is a lifecycle resume whose edit is verifiably already + on the base: the manifest then says `alreadyApplied` and seals no file, and the + base itself is what gets validated, built, and recorded. """ require(bool(COMMIT_SHA_RE.fullmatch(base or "")), "base is not an exact commit SHA") require(git(repo, "rev-parse", f"{base}^{{commit}}").stdout.strip() == base, "base is not an exact commit") paths = changed_paths(repo, base) - require(bool(paths), "implementation produced no patch") + already_applied = not paths and is_lifecycle_resume(plan) + if already_applied: + validate_lifecycle_on_base(repo, plan, recorded_action_keys(repo / "autorelease-events")) + else: + require(bool(paths), "implementation produced no patch") admitted = [ item for patterns in plan.get("allowedPaths", {}).values() @@ -662,6 +780,7 @@ def seal_patch( "planDigest": sha256_bytes(canonical_json(plan)), "patchDigest": sha256_file(patch_path), "files": files, + "alreadyApplied": already_applied, "sealedAt": utc_now(), } write_json(output_dir / "patch-manifest.json", manifest) @@ -683,6 +802,10 @@ def verify_merge( base whose diff is exactly the sealed file set, byte for byte and mode for mode. Every check in `REQUIRED_PLAN_CHECKS` must be reported as successful, the recorded preconditions must still hold, and any readiness record must be ready. + + An `alreadyApplied` manifest seals no file, so there is nothing to merge: the + validated head must be the sealed base itself and still be main, which the + unchanged preconditions prove. """ require(bool(COMMIT_SHA_RE.fullmatch(expected_head or "")), "expected head is not an exact commit SHA") actual_head = git(repo, "rev-parse", "HEAD").stdout.strip() @@ -696,6 +819,28 @@ def verify_merge( require(preconditions == current, "merge preconditions changed") base_sha = manifest.get("baseSha") require(bool(COMMIT_SHA_RE.fullmatch(base_sha or "")), "sealed manifest has no exact base SHA") + file_records = manifest.get("files", []) + require(isinstance(file_records, list), "sealed manifest files are invalid") + if manifest.get("alreadyApplied") is True: + require(not file_records, "an already-applied manifest cannot seal file changes") + require(expected_head == base_sha, "an already-applied lifecycle must validate its sealed base itself") + require(current.get("phpBinHead") == expected_head, "main is not the validated commit") + else: + require(bool(file_records), "a sealed manifest without files must be an already-applied lifecycle") + _verify_sealed_commit(repo, expected_head, base_sha, file_records) + for record in readiness or []: + require(record.get("ready") is True, "cross-repository readiness is missing") + require(bool(record.get("commit")), "readiness record has no exact commit") + return {"admitted": True, "headSha": actual_head, "verifiedAt": utc_now()} + + +def _verify_sealed_commit( + repo: pathlib.Path, + expected_head: str, + base_sha: str, + file_records: list[Any], +) -> None: + """Require `expected_head` to be one commit on `base_sha` changing exactly the sealed files.""" require( git(repo, "rev-list", "--parents", "-n", "1", expected_head).stdout.split() == [expected_head, base_sha], @@ -712,8 +857,6 @@ def verify_merge( "--", ).stdout.splitlines() ) - file_records = manifest.get("files", []) - require(isinstance(file_records, list), "sealed manifest files are invalid") manifest_paths = {item.get("path") for item in file_records if isinstance(item, dict)} require(len(manifest_paths) == len(file_records) and None not in manifest_paths, "sealed manifest paths are invalid") require(actual_paths == manifest_paths, "final diff does not equal the sealed manifest") @@ -729,7 +872,3 @@ def verify_merge( oct(candidate.stat().st_mode & 0o777) == file_record.get("mode"), f"validated file mode changed: {path}", ) - for record in readiness or []: - require(record.get("ready") is True, "cross-repository readiness is missing") - require(bool(record.get("commit")), "readiness record has no exact commit") - return {"admitted": True, "headSha": actual_head, "verifiedAt": utc_now()} diff --git a/autorelease/_classifier.py b/autorelease/_classifier.py index 5964f44..c4ed167 100644 --- a/autorelease/_classifier.py +++ b/autorelease/_classifier.py @@ -11,11 +11,16 @@ that is only waiting for mise-php readiness lets a due patch go first. 3. A `new_patch` per maintained branch, oldest branch first, for the newest stable version that branch's own feed names when it is neither published nor superseded. - A maintained branch that never shipped a release needs a human instead: its first - release belongs to `new_branch`, behind the cross-repository readiness gate. -4. Lifecycle evidence on the supported-versions page: a `new_branch`, then a - `branch_eol`. Patches never read that page, so an unreadable page blocks only a run - with no patch due. + A maintained branch that never shipped a release is never a patch: its first + release belongs to `new_branch`, behind the cross-repository readiness gate. When + the accepted policy was written by that branch's `new_branch` edit and no record + exists, the edit merged in a run that stopped before recording readiness, so the + lifecycle resumes; any other such branch needs a human. +4. Lifecycle evidence on the supported-versions page: first a retirement whose edit + already merged without a record resumes, then a `new_branch`, then a `branch_eol`. + No fresh edit starts while a merged `new_branch` edit still waits to resume. + Patches never read that page, so an unreadable page blocks only a run with no patch + due. 5. The one `recipe_rebuild` the watch decision selected. 6. `no_change`, keyed on the manifest's embedded `manifestDigest`. @@ -264,6 +269,7 @@ def __init__( preconditions: dict[str, Any], events: Iterable[dict[str, Any]], maintained_branches: list[str], + accepted_policy_key: str | None, ): self.capture = _Capture(manifest_path) require(isinstance(preconditions, dict), "preconditions must be an object") @@ -286,6 +292,11 @@ def __init__( "maintained branches are invalid", ) self.maintained = sorted(set(maintained_branches), key=version_key) + require( + accepted_policy_key is None or isinstance(accepted_policy_key, str), + "accepted policy action key is invalid", + ) + self.accepted_policy_key = accepted_policy_key self.completed = {key for key, event in self.events.items() if event.get("state") == "complete"} # Plan construction --------------------------------------------------------------- @@ -606,6 +617,11 @@ def lifecycle(self) -> dict[str, Any] | None: "released, or an older supported branch is not maintained. The policy needs a " "reviewed change.", ) + resumed = self.resumed_retirement(rows) + if resumed is not None: + return resumed + if self.branch_resume_pending(): + return None for branch in sorted((set(rows) - maintained), key=version_key): row = rows[branch] if row.state not in SUPPORTED_STATES: @@ -657,6 +673,70 @@ def lifecycle(self) -> dict[str, Any] | None: ) return None + def lifecycle_resume( + self, + action: str, + action_key: str, + evidence: list[dict[str, Any]], + summary: str, + release_intent: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Return a plan that resumes a lifecycle edit already merged on main. + + It requires the implementation run but allows no path, so that run can only + verify the edit is present, validate and (for a new branch) build main's exact + commit, and file the readiness record for it. Admission re-checks the edit and + the missing record against the checked-out base independently. + """ + return self.plan( + action=action, + action_key=action_key, + evidence=evidence, + edits_required=True, + allowed_php_bin=[], + repositories=["php-bin", "mise-php"], + release_intent=release_intent, + risk="lifecycle", + summary=summary, + ) + + def resumed_retirement(self, rows: dict[str, SupportRow]) -> dict[str, Any] | None: + """Resume a retirement whose policy edit merged but whose record never landed. + + Nothing else would notice it: the branch is no longer maintained, so no rule + reads it again. The branch's end-of-life row is cited while php.net still lists + it; once php.net drops the row, the capture that no longer lists it is. A branch + the page still calls supported is a contradiction for the rules below instead. + """ + key = self.accepted_policy_key or "" + match = re.fullmatch(r"branch_eol:(\d+\.\d+):\d{4}-\d{2}-\d{2}", key) + if not match or match.group(1) in self.maintained or key in self.events: + return None + branch = match.group(1) + row = rows.get(branch) + if row is not None and row.state != "eol": + return None + if row is not None: + cited = self.capture.fragment( + "php_supported_versions", + row.fragment, + f"php.net lists PHP {branch} as end of life since {row.security_until.isoformat()}.", + ) + else: + cited, _value = self.capture.pointer( + "evidence_manifest", + f"/captures/{self.capture.index['php_supported_versions']}/digest", + f"php.net no longer lists PHP {branch} among its branches.", + ) + return self.lifecycle_resume( + "branch_eol", + key, + [cited], + f"PHP {branch} already left the maintained set on main, but no {key} record exists. " + "The retirement resumes without a new edit: main's exact commit is validated and " + "its readiness record filed.", + ) + def waiting_lifecycle_record(self) -> bool: """Tell whether the only incomplete record is lifecycle work waiting for mise-php. @@ -688,11 +768,31 @@ def resume_incomplete(self) -> dict[str, Any] | None: # stops as `needs_human` in `new_patch`, and that stop yields to the resume. return patch if patch is not None and patch["action"] == "new_patch" else resumed - def new_patch(self) -> dict[str, Any] | None: - published = self.published_versions() - shipped = {branch_of(version) for version in published} | { + def shipped_branches(self, published: set[str]) -> set[str]: + """Return every branch with a published release or a completed `new_branch` record.""" + return {branch_of(version) for version in published} | { key.partition(":")[2] for key in self.completed if key.startswith("new_branch:") } + + def branch_resume_pending(self) -> bool: + """Tell whether the accepted policy is an unrecorded `new_branch` edit still owed a resume. + + `new_patch` resumes it, or skips it while a feed supersedes the branch's first + release. Until then no fresh lifecycle edit may start: that edit rewrites the + policy's action key, and with it the only sign the resume is owed. A record for + the key never reaches this rule, since `new_patch` stops on any unshipped branch + that has one. + """ + match = re.fullmatch(r"new_branch:(\d+\.\d+)", self.accepted_policy_key or "") + return ( + bool(match) + and match.group(1) in self.maintained + and match.group(1) not in self.shipped_branches(self.published_versions()) + ) + + def new_patch(self) -> dict[str, Any] | None: + published = self.published_versions() + shipped = self.shipped_branches(published) for branch in self.maintained: version = self.branch_feed_version(branch) if version is None and not self.capture.has(branch_feed_capture_id(branch)): @@ -709,14 +809,29 @@ def new_patch(self) -> dict[str, Any] | None: # plain patch would skip the mise-php readiness gate. proof = self.feed_proof(version) require(proof is not None, f"branch feed version {version} is not provable") + key = f"new_branch:{branch}" + if self.accepted_policy_key == key and key not in self.events: + # The accepted policy is this branch's own lifecycle edit, which + # merged in a run that stopped before recording readiness. + if self.superseded_by(version): + continue + return self.lifecycle_resume( + "new_branch", + key, + [proof], + f"PHP {branch} is already maintained on main, but no {key} record exists. " + f"The lifecycle resumes without a new edit: main's exact commit is validated " + f"and PHP {version} built at it before its readiness record is filed.", + release_intent={"version": version, "sourceIdentifier": proof["captureId"]}, + ) return self.stop( "needs_human", - f"new_branch:{branch}", + key, [proof], f"PHP {branch} is maintained and its feed names {version}, but no {branch} release " - f"has shipped and no new_branch:{branch} record is in flight. Its first release " - "must pass the cross-repository readiness gate, so an operator needs to restore " - "the missing event record.", + f"has shipped, no {key} record is in flight, and the accepted policy was not " + f"written by {key}. Its first release must pass the cross-repository readiness " + "gate, so an operator needs to restore the missing event record.", ) newest_published = max( (item for item in published if branch_of(item) == branch), key=version_key, default=None @@ -796,16 +911,19 @@ def classify_evidence( preconditions: dict[str, Any], events: Iterable[dict[str, Any]], maintained_branches: list[str], + accepted_policy_key: str | None = None, ) -> dict[str, Any]: """Classify one retained watcher capture into exactly one autorelease plan. `manifest_path` is `autorelease-run/evidence/evidence-manifest.json`; the watch decision is read from its fixed runtime location beside it, the same file a plan may cite as `watch_decision`. `events` are the durable records under - `autorelease-events/`, and `maintained_branches` the accepted policy's branches. + `autorelease-events/`, `maintained_branches` the accepted policy's branches, and + `accepted_policy_key` the action key that policy was written by. Without it no + merged lifecycle edit is resumed. The same inputs always produce the same plan bytes. A malformed input that the watcher itself produced (manifest, decision, preconditions, records) raises `ControlError` and fails the job; a malformed upstream body becomes a `blocked` plan instead. """ - return _Classifier(manifest_path, preconditions, events, maintained_branches).classify() + return _Classifier(manifest_path, preconditions, events, maintained_branches, accepted_policy_key).classify() diff --git a/autorelease/_implementation.py b/autorelease/_implementation.py index 2ac28ae..477ecd7 100644 --- a/autorelease/_implementation.py +++ b/autorelease/_implementation.py @@ -8,6 +8,11 @@ action key, and accepted at the capture time, so the same admitted plan always produces the same bytes. +A lifecycle resume (`_admission.is_lifecycle_resume`) allows no path at all: its edit +merged in an earlier run that stopped before recording readiness. The edit is then +verified present on the base and nothing is written, so the run validates, builds, and +records the exact commit already on main. + These edits are proposals, not authority. `_admission.seal_patch` re-checks every path against the plan, the policy against its invariants and evidence, and the clean validation and exact-SHA merge gates run after it. A copied module list that does not @@ -23,7 +28,12 @@ import re from typing import Any -from ._admission import validate_support_policy +from ._admission import ( + is_lifecycle_resume, + recorded_action_keys, + validate_lifecycle_on_base, + validate_support_policy, +) from ._validation import ControlError, contained_path, require, sha256_file @@ -70,11 +80,15 @@ def apply_lifecycle_plan( `repo` is a checkout of the plan's exact base commit and `manifest` the evidence manifest the plan was admitted against. The accepted policy in `repo` must still validate, and a plan for any other action is rejected: only lifecycle work edits the - repository. + repository. A lifecycle resume writes nothing and returns no path, once its edit is + verified present and no event record exists for it. """ action = plan.get("action") require(action in LIFECYCLE_ACTIONS, f"no deterministic repository edit exists for action: {action}") require(plan.get("editsRequired") is True, "lifecycle plan does not require edits") + if is_lifecycle_resume(plan): + validate_lifecycle_on_base(repo, plan, recorded_action_keys(repo / "autorelease-events")) + return [] key = plan.get("actionKey", "") match = re.fullmatch(r"(?:new_branch:(\d+\.\d+)|branch_eol:(\d+\.\d+):\d{4}-\d{2}-\d{2})", key) require(bool(match), f"lifecycle action key is invalid: {key}") diff --git a/autorelease/control.py b/autorelease/control.py index 63d3580..3c49875 100755 --- a/autorelease/control.py +++ b/autorelease/control.py @@ -50,8 +50,11 @@ _validate_support_policy_document, changed_paths, git, + is_lifecycle_resume, recipe_identity, + recorded_action_keys, seal_patch, + validate_lifecycle_on_base, validate_plan, validate_recipe_rebuild_evidence, validate_release_is_newest_patch, @@ -393,6 +396,7 @@ def main(argv: list[str] | None = None) -> int: load_json(args.preconditions), load_event_records(args.events), validate_support_policy(ROOT)["maintainedBranches"], + load_json(ROOT / "support-policy.json")["actionKey"], ) write_json(args.output, plan) print(json.dumps({"action": plan["action"], "actionKey": plan["actionKey"]})) diff --git a/scripts/admit-autorelease-plan b/scripts/admit-autorelease-plan index 99ff2f5..506377c 100755 --- a/scripts/admit-autorelease-plan +++ b/scripts/admit-autorelease-plan @@ -13,6 +13,7 @@ from autorelease.control import ( # noqa: E402 ControlError, due_recipe_rebuild, load_json, + recorded_action_keys, validate_plan, write_json, ) @@ -25,6 +26,9 @@ parser.add_argument("--php-bin-head", required=True) parser.add_argument("--mise-php-head", required=True) parser.add_argument("--policy-digest", required=True) parser.add_argument("--completed-actions", type=pathlib.Path, required=True) +# The durable event records at the checked-out base. A lifecycle resume is admitted +# only when none exists for its key, so without them a resume is rejected. +parser.add_argument("--events", type=pathlib.Path) parser.add_argument("--output", type=pathlib.Path, required=True) args = parser.parse_args() @@ -46,6 +50,8 @@ try: args.policy_digest, completed, pending_rebuild, + ROOT, + recorded_action_keys(args.events) if args.events else None, ) write_json(args.output, admission) print(json.dumps(admission)) diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index 1b633a1..786bac3 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -2208,6 +2208,226 @@ def run_step(work, clone, origin, script, open_prs="", extra=None): (main_record is not None, merge_outcome, already), ) + def test_a_resumed_lifecycle_records_the_validated_main_commit_without_a_merge(self): + from autorelease.control import classify_evidence, render_support_policy + from autorelease.verify import FIXTURE_HEADS, fixture_capture, load_workflow, support_page + + root = pathlib.Path(__file__).resolve().parents[1] + jobs = load_workflow(root / ".github/workflows/autorelease-implement.yml")["jobs"] + + def step(job, name): + return next(item for item in jobs[job]["steps"] if item.get("name") == name) + + # The lifecycle PR and its merge run only for a real edit; an already-applied + # edit takes the on-main step instead, and the record binds whichever ran. + skipped = "needs.implement.outputs.already_applied != 'true'" + self.assertEqual("${{ steps.sealed.outputs.already_applied }}", jobs["implement"]["outputs"]["already_applied"]) + for name in ("Create or reuse automation PR", "Wait for required checks on exact head", + "Re-verify exact SHA, sealed tree, and preconditions", "Merge admitted exact commit"): + self.assertEqual(skipped, step("merge", name).get("if"), name) + on_main = step("merge", "Re-verify the already-merged edit at the validated commit") + self.assertEqual("needs.implement.outputs.already_applied == 'true'", on_main["if"]) + readiness = step("merge", "Commit deterministic php_bin_ready event record") + self.assertEqual( + "${{ steps.merged.outputs.commit || steps.onmain.outputs.commit }}", readiness["env"]["MERGED_COMMIT"] + ) + self.assertNotIn("if", readiness) + self.assertNotIn("steps.merged.outputs", readiness["run"]) + scripts = [ + step("implement", "Apply the admitted lifecycle edit")["run"], + step("implement", "Seal exact deterministic diff")["run"], + step("validate", "Apply exact sealed bytes")["run"], + step("validate", "Record validated SHA and tree")["run"], + step("merge", "Restore exact validated commit")["run"], + on_main["run"], + readiness["run"].replace("${{ github.run_id }}", "77"), + ] + for script in scripts: + self.assertNotIn("${{", script) + # The branch build restores the validated commit exactly as the merge job does. + build_restore = step("build-new-branch", "Restore exact validated commit") + self.assertTrue(build_restore["run"].startswith(scripts[4]), build_restore["run"]) + self.assertEqual(step("merge", "Restore exact validated commit")["env"], build_restore["env"]) + + def git_in(path, *args): + return subprocess.run( + ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@invalid", *args], + cwd=path, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ).stdout.strip() + + def fixture(work): + """Return a clone of an origin whose main already carries new_branch:8.6.""" + source = work / "source" + tracked = git_in(root, "ls-files", "-z").split("\0") + for path in filter(None, tracked): + if (root / path).is_file(): + (source / path).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(root / path, source / path) + policy = json.loads((source / "support-policy.json").read_text()) + maintained = [*policy["maintainedBranches"], "8.6"] + policy.update(maintainedBranches=maintained, sourceEvidenceDigests=["sha256:" + "e" * 64], + actionKey="new_branch:8.6", acceptedAt="2026-09-27T00:00:00Z") + (source / "support-policy.json").write_text(render_support_policy(policy)) + shutil.copy(source / f"expected-modules/{maintained[-2]}.txt", source / "expected-modules/8.6.txt") + git_in(work, "init", "-q", "-b", "main", str(source)) + git_in(source, "add", "-A") + git_in(source, "commit", "-q", "-m", "chore: new_branch:8.6") + origin = work / "origin.git" + git_in(work, "clone", "-q", "--bare", str(source), str(origin)) + clone = work / "clone" + git_in(work, "clone", "-q", str(origin), str(clone)) + base = git_in(clone, "rev-parse", "HEAD") + git_in(clone, "checkout", "-q", "--detach", base) + # The admitted resume plan, exactly as the watcher retains it. + feeds = {branch: f"{branch}.1" for branch in maintained} + feeds["8.6"] = "8.6.0" + rows = {branch: ("stable", "31 Dec 2030") for branch in maintained} + manifest = fixture_capture( + clone / "autorelease-run", branch_feeds=feeds, aggregate="8.6.0", page=support_page(rows), + releases=[{"tag_name": f"{branch}.1"} for branch in maintained[:-1]], + ) + preconditions = {**FIXTURE_HEADS, "phpBinHead": base, + "supportPolicyDigest": sha256_file(clone / "support-policy.json")} + plan = classify_evidence(manifest, preconditions, [], maintained, "new_branch:8.6") + self.assertEqual(("new_branch:8.6", True, []), (plan["actionKey"], plan["editsRequired"], plan["allowedPaths"]["php-bin"])) + (clone / "autorelease-run/autorelease-plan.json").write_text(json.dumps(plan)) + (work / "bin").mkdir() + (work / "bin/gh").write_text( + "#!/usr/bin/env bash\n" + 'echo "$*" >> "$FAKE_LOG"\n' + 'case "$1 $2" in\n' + ' "pr create") echo https://github.com/o/r/pull/9 ;;\n' + ' "pr list") echo "${FAKE_PRS:-[]}" ;;\n' + ' "pr close") ;;\n' + ' "auth setup-git") ;;\n' + " *) exit 3 ;;\n" + "esac\n" + ) + (work / "bin/gh").chmod(0o755) + return clone, origin, base + + def run_step(work, clone, script, **env): + output = work / "output.txt" + output.write_text("") + result = subprocess.run( + ["bash", "-eo", "pipefail", "-c", script], cwd=clone, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env={**os.environ, "PATH": f"{work / 'bin'}:{os.environ['PATH']}", "GITHUB_OUTPUT": str(output), + "FAKE_LOG": str(work / "gh.log"), "GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@invalid", + "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@invalid", **env}, + ) + outputs = dict(line.split("=", 1) for line in output.read_text().splitlines() if "=" in line) + return result, outputs + + def run_through_on_main(work, clone, base): + """Run implement, validate, and the merge job's checks; return the on-main outputs.""" + for script in scripts[:5]: + result, outputs = run_step(work, clone, script, BASE_SHA=base) + self.assertEqual(0, result.returncode, result.stderr) + if "already_applied=" in script: + self.assertEqual("true", outputs["already_applied"]) + validation = json.loads((clone / "autorelease-run/validation.json").read_text()) + self.assertEqual((base, git_in(clone, "rev-parse", "HEAD^{tree}")), (validation["headSha"], validation["tree"])) + self.assertFalse((clone / "autorelease-run/validated.bundle").exists()) + return run_step(work, clone, scripts[5]) + + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin, base = fixture(work) + result, outputs = run_through_on_main(work, clone, base) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(base, outputs["commit"]) + # An earlier attempt's readiness PR for this key is closed; nothing else is. + open_prs = [ + {"number": 5, "headRefName": "autorelease/readiness-41", "title": "chore: record new_branch:8.6 php-bin readiness"}, + {"number": 6, "headRefName": "autorelease/readiness-42", "title": "chore: record new_branch:8.7 php-bin readiness"}, + {"number": 7, "headRefName": "autorelease/new_branch-8.6", "title": "chore: record new_branch:8.6 php-bin readiness"}, + {"number": 8, "headRefName": "autorelease/readiness-77", "title": "chore: record new_branch:8.6 php-bin readiness"}, + ] + result, outputs = run_step(work, clone, scripts[6], MERGED_COMMIT=outputs["commit"], FAKE_PRS=json.dumps(open_prs), + GITHUB_REPOSITORY="o/r") + self.assertEqual(0, result.returncode, result.stderr) + # Only the readiness record reached a branch and a PR: nothing was merged. + calls = (work / "gh.log").read_text() + self.assertNotIn("pr merge", calls) + self.assertIn("pr list --state open --author app/github-actions", calls) + self.assertEqual(["pr close 5 --repo o/r --delete-branch"], + [line.split(" --comment")[0] for line in calls.splitlines() if line.startswith("pr close")]) + self.assertEqual(1, calls.count("pr create")) + self.assertIn("--head autorelease/readiness-77", calls) + self.assertEqual("autorelease/readiness-77", git_in(origin, "branch", "--list", "autorelease/*").strip("* ")) + self.assertEqual(base, git_in(origin, "rev-parse", "main")) + head = git_in(origin, "rev-parse", "autorelease/readiness-77") + self.assertEqual((base, head), (outputs["base_sha"], outputs["head_sha"])) + self.assertEqual(f"{head} {base}", git_in(origin, "rev-list", "--parents", "-n", "1", head)) + record_path = "autorelease-events/new_branch-8.6.json" + self.assertEqual(record_path, git_in(origin, "diff", "--name-only", base, head)) + record = json.loads(git_in(origin, "show", f"{head}:{record_path}")) + # The record binds the validated main commit and the policy of that tree. + self.assertEqual(("php_bin_ready", "new_branch", base), (record["state"], record["classification"], record["phpBinCommit"])) + self.assertEqual( + [{"kind": "validated_merge", "commit": base, "planDigest": record["planDigest"]}], + record["history"][0]["evidence"], + ) + self.assertEqual(sha256_bytes(git_in(origin, "show", f"{base}:support-policy.json").encode() + b"\n"), + record["supportPolicyDigest"]) + self.assertEqual(sha256_file(clone / "autorelease/policy-invariants.json"), record["policyInvariantsDigest"]) + + # Each step refuses any state other than an untouched admitted base. + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin, base = fixture(work) + for script in scripts[:2]: + result, _outputs = run_step(work, clone, script, BASE_SHA=base) + self.assertEqual(0, result.returncode, result.stderr) + sealed = clone / "autorelease-run/sealed" + manifest = json.loads((sealed / "patch-manifest.json").read_text()) + + def refused(script, label, **env): + result, _outputs = run_step(work, clone, script, **{"BASE_SHA": base, **env}) + self.assertNotEqual(0, result.returncode, label) + + (sealed / "patch-manifest.json").write_text(json.dumps({**manifest, "files": [{"path": "support-policy.json"}]})) + refused(scripts[2], "a sealed file rides along") + (sealed / "sealed.patch").write_text("diff\n") + (sealed / "patch-manifest.json").write_text( + json.dumps({**manifest, "patchDigest": sha256_file(sealed / "sealed.patch")})) + refused(scripts[2], "a non-empty patch") + (sealed / "sealed.patch").write_text("") + (sealed / "patch-manifest.json").write_text(json.dumps(manifest)) + refused(scripts[3], "the checkout is not the admitted base", BASE_SHA="0" * 40) + (clone / "staged.txt").write_text("staged\n") + git_in(clone, "add", "staged.txt") + refused(scripts[3], "a staged change") + git_in(clone, "rm", "-q", "--cached", "staged.txt") + (clone / "staged.txt").unlink() + policy = (clone / "support-policy.json").read_text() + (clone / "support-policy.json").write_text(policy + "\n") + refused(scripts[3], "an unstaged change to a tracked file") + (clone / "support-policy.json").write_text(policy) + result, _outputs = run_step(work, clone, scripts[3], BASE_SHA=base) + self.assertEqual(0, result.returncode, result.stderr) + validation = json.loads((clone / "autorelease-run/validation.json").read_text()) + (clone / "autorelease-run/validation.json").write_text(json.dumps({**validation, "headSha": "0" * 40})) + refused(scripts[4], "a validated commit other than the base") + self.assertFalse((work / "gh.log").exists()) + + # Main moving on after validation stops the run before any record is filed. + with tempfile.TemporaryDirectory() as temporary: + work = pathlib.Path(temporary) + clone, origin, base = fixture(work) + other = work / "other" + git_in(work, "clone", "-q", str(origin), str(other)) + (other / "later.txt").write_text("later\n") + git_in(other, "add", "later.txt") + git_in(other, "commit", "-q", "-m", "later") + git_in(other, "push", "-q", "origin", "main") + result, outputs = run_through_on_main(work, clone, base) + self.assertNotEqual(0, result.returncode) + self.assertIn("merge admission rejected", result.stderr) + self.assertNotIn("commit", outputs) + self.assertFalse((work / "gh.log").exists()) + def test_publish_runs_email_their_own_digest_exactly_once(self): from autorelease.verify import load_workflow diff --git a/tests/test_classifier.py b/tests/test_classifier.py index 465e91c..7b31f58 100644 --- a/tests/test_classifier.py +++ b/tests/test_classifier.py @@ -14,12 +14,14 @@ canonical_json, classify_evidence, parse_supported_versions, + recorded_action_keys, render_support_policy, route_watch_action, seal_patch, sha256_bytes, sha256_file, validate_plan, + verify_merge, ) from autorelease.verify import FIXTURE_HEADS, FIXTURE_POLICY_DIGEST, fixture_capture, support_page @@ -71,6 +73,50 @@ def classify(self, manifest, events=(), branches=("8.4", "8.5")): def admit(self, plan, manifest, pending=None, completed=()): return validate_plan(plan, manifest, FIXTURE_HEADS, FIXTURE_POLICY_DIGEST, set(completed), pending) + def base(self, maintained, key, modules=(), records=()): + """Return a checked-out base whose accepted policy was written by `key`.""" + self.count += 1 + repo = self.tmp / f"base-{self.count}" + (repo / "autorelease").mkdir(parents=True) + shutil.copy(ROOT / "autorelease/policy-invariants.json", repo / "autorelease/policy-invariants.json") + policy = { + "schemaVersion": 1, + "policyInvariantsDigest": sha256_file(repo / "autorelease/policy-invariants.json"), + "maintainedBranches": list(maintained), + "sourceEvidenceDigests": ["sha256:" + "e" * 64], + "actionKey": key, + "acceptedAt": "2026-09-27T00:00:00Z", + } + (repo / "support-policy.json").write_text(render_support_policy(policy)) + (repo / "expected-modules").mkdir() + for branch in modules: + (repo / f"expected-modules/{branch}.txt").write_text("Core\n") + (repo / "autorelease-events").mkdir() + for record_key in records: + (repo / f"autorelease-events/{record_key.replace(':', '-')}.json").write_text( + json.dumps({"actionKey": record_key, "state": "complete"}) + ) + return repo + + def classify_on(self, manifest, repo, events=None): + """Classify exactly as the watcher does on the checked-out `repo` and its records.""" + if events is None: + events = [json.loads(path.read_text()) for path in sorted((repo / "autorelease-events").glob("*.json"))] + policy = json.loads((repo / "support-policy.json").read_text()) + preconditions = {**FIXTURE_HEADS, "supportPolicyDigest": sha256_file(repo / "support-policy.json")} + return classify_evidence(manifest, preconditions, list(events), policy["maintainedBranches"], policy["actionKey"]) + + def admit_on(self, plan, manifest, repo, completed=(), keys="records"): + """Admit exactly as the watcher's admission script does on the checked-out `repo`. + + The plan is bound to `repo`'s policy digest, as if classified there, so a rejection + comes from the base's contents rather than from a stale policy precondition. + """ + digest = sha256_file(repo / "support-policy.json") + plan = {**plan, "preconditions": {**plan["preconditions"], "supportPolicyDigest": digest}} + recorded = recorded_action_keys(repo / "autorelease-events") if keys == "records" else keys + return validate_plan(plan, manifest, FIXTURE_HEADS, digest, set(completed), None, repo, recorded) + # The supported-versions reader ---------------------------------------------------- def test_reader_parses_the_reviewed_table_and_ignores_the_key(self): @@ -402,6 +448,135 @@ def test_a_maintained_branch_that_never_shipped_is_never_a_plain_patch(self): self.admit(patch, manifest) self.admit(patch, manifest, completed=["new_branch:8.6"]) + def test_a_merged_new_branch_without_its_record_resumes_the_lifecycle(self): + # The new_branch:8.6 edit merged, then its run stopped before the readiness + # record: main maintains 8.6, the policy carries that edit's key, and no record + # or release exists. + feeds = {"8.4": "8.4.20", "8.5": "8.5.9", "8.6": "8.6.0"} + rows = {**MAINTAINED, "8.6": ("stable", "31 Dec 2030")} + repo = self.base(("8.4", "8.5", "8.6"), "new_branch:8.6", modules=("8.4", "8.5", "8.6")) + manifest = self.capture(feeds=feeds, rows=rows, aggregate="8.6.0") + plan = self.classify_on(manifest, repo) + self.assertEqual(("new_branch", "new_branch:8.6", True), (plan["action"], plan["actionKey"], plan["editsRequired"])) + self.assertEqual({"php-bin": [], "mise-php": []}, plan["allowedPaths"]) + self.assertEqual({"version": "8.6.0", "sourceIdentifier": "php_release_feed_8.6"}, plan["releaseIntent"]) + self.assertEqual(["php_release_feed_8.6"], [item["captureId"] for item in plan["evidence"]]) + self.assertEqual(("lifecycle", ["php-bin", "mise-php"]), (plan["risk"], plan["repositories"])) + self.admit_on(plan, manifest, repo) + decision = route_watch_action(plan) + self.assertEqual(("dispatch_implementation", "lifecycle"), (decision["route"], decision["notify"])) + # A recovered record moving main defers it like any other implementation. + deferred = route_watch_action({**plan, "recoveryMerged": True}) + self.assertEqual(("none", "dispatch_deferred_by_recovery"), (deferred["route"], deferred["reason"])) + + # Admission re-checks the base on its own and rejects anything short of it. + rejected = { + "admitted only against the checked-out base": lambda: validate_plan( + plan, manifest, FIXTURE_HEADS, sha256_file(repo / "support-policy.json"), set(), None), + "event records needed to resume new_branch:8.6 were not supplied": lambda: self.admit_on( + plan, manifest, repo, keys=None), + "an event record for new_branch:8.6 already exists": lambda: self.admit_on( + plan, manifest, repo, keys={"new_branch:8.6"}), + "policy was not written by new_branch:8.6": lambda: self.admit_on( + plan, manifest, self.base(("8.4", "8.5", "8.6"), "branch_eol:8.3:2025-12-31", modules=("8.6",))), + "does not maintain PHP 8.6": lambda: self.admit_on( + plan, manifest, self.base(("8.4", "8.5"), "new_branch:8.6", modules=("8.6",))), + "module list of PHP 8.6 is missing": lambda: self.admit_on( + plan, manifest, self.base(("8.4", "8.5", "8.6"), "new_branch:8.6", modules=("8.5",))), + "PHP 8.6 already shipped": lambda: self.admit_on( + plan, self.capture(feeds=feeds, rows=rows, aggregate="8.6.0", releases=[*PUBLISHED, {"tag_name": "8.6.0"}]), + repo), + } + for reason, admit in rejected.items(): + with self.assertRaisesRegex(ControlError, reason): + admit() + # A completed record naming a release on the branch also means it shipped. + with self.assertRaisesRegex(ControlError, "PHP 8.6 already shipped"): + self.admit_on(plan, manifest, repo, completed=["new_patch:8.6.0"]) + # The checked-out policy must be the one the plan was classified against. + other = self.base(("8.4", "8.5", "8.6"), "new_branch:8.6", modules=("8.6",)) + (other / "support-policy.json").write_text((other / "support-policy.json").read_text().replace("e" * 64, "f" * 64)) + with self.assertRaisesRegex(ControlError, "not the one the plan was classified against"): + validate_plan(plan, manifest, FIXTURE_HEADS, sha256_file(repo / "support-policy.json"), set(), None, other, + recorded_action_keys(other / "autorelease-events")) + + # Without the edit's own key on the policy a human still restores the record. + other = self.base(("8.4", "8.5", "8.6"), "bootstrap", modules=("8.6",)) + stopped = self.classify_on(manifest, other) + self.assertEqual(("needs_human", "new_branch:8.6"), (stopped["action"], stopped["actionKey"])) + self.assertEqual("needs_human", self.classify(manifest, branches=("8.4", "8.5", "8.6"))["action"]) + # A record in flight is resumed through its own next transition instead. + waiting = self.capture(feeds=feeds, rows=rows, aggregate="8.6.0", incomplete=["new_branch:8.6"]) + resumed = self.classify_on(waiting, repo, [{"actionKey": "new_branch:8.6", "state": "php_bin_ready"}]) + self.assertEqual(("new_branch:8.6", False), (resumed["actionKey"], resumed["editsRequired"])) + # Any record for the key, even one the watch decision did not list, rules it out. + listed = self.classify_on(manifest, repo, [{"actionKey": "new_branch:8.6", "state": "php_bin_ready"}]) + self.assertEqual(("needs_human", "new_branch:8.6"), (listed["action"], listed["actionKey"])) + # A due patch on an older branch still goes first. + due = self.capture(feeds={**feeds, "8.4": "8.4.21"}, rows=rows, aggregate="8.6.0") + self.assertEqual("new_patch:8.4.21", self.classify_on(due, repo)["actionKey"]) + # A first release the aggregate feed already supersedes waits for its own feed. + ahead = self.capture(feeds=feeds, rows=rows, aggregate="8.6.1") + self.assertEqual("no_change", self.classify_on(ahead, repo)["action"]) + # While it waits, no fresh lifecycle edit rewrites the policy's key and loses it. + retiring = {**rows, "8.4": ("eol", "31 Dec 2025")} + waiting_eol = self.capture(feeds=feeds, rows=retiring, aggregate="8.6.1") + self.assertEqual("no_change", self.classify_on(waiting_eol, repo)["action"]) + # Once the branch has shipped, lifecycle work goes on as before. + shipped = self.capture(feeds=feeds, rows=retiring, aggregate="8.6.0", releases=[*PUBLISHED, {"tag_name": "8.6.0"}]) + self.assertEqual("branch_eol:8.4:2025-12-31", self.classify_on(shipped, repo)["actionKey"]) + # So does a policy that carries the key but no longer maintains the branch. + dropped = self.base(("8.4", "8.5"), "new_branch:8.6", modules=("8.4", "8.5")) + unmaintained = self.capture(feeds={"8.4": "8.4.20", "8.5": "8.5.9"}, rows={**MAINTAINED, "8.4": ("eol", "31 Dec 2025")}) + self.assertEqual("branch_eol:8.4:2025-12-31", self.classify_on(unmaintained, dropped)["actionKey"]) + + def test_a_merged_retirement_without_its_record_resumes_the_lifecycle(self): + key = "branch_eol:8.4:2026-12-31" + repo = self.base(("8.5",), key, modules=("8.4", "8.5")) + feeds = {"8.5": "8.5.9"} + # php.net still lists the retired branch: its end-of-life row is the evidence. + manifest = self.capture(feeds=feeds, rows={**MAINTAINED, "8.4": ("eol", "31 Dec 2026")}) + plan = self.classify_on(manifest, repo) + self.assertEqual(("branch_eol", key, True), (plan["action"], plan["actionKey"], plan["editsRequired"])) + self.assertEqual({"php-bin": [], "mise-php": []}, plan["allowedPaths"]) + self.assertIsNone(plan["releaseIntent"]) + self.assertIn('', plan["evidence"][0]["locator"]["value"]) + self.admit_on(plan, manifest, repo) + self.assertEqual("dispatch_implementation", route_watch_action(plan)["route"]) + # Once php.net drops the row, the capture that no longer lists it is cited. + dropped = self.capture(feeds=feeds, rows={"8.5": MAINTAINED["8.5"]}) + plan = self.classify_on(dropped, repo) + self.assertEqual((key, "evidence_manifest"), (plan["actionKey"], plan["evidence"][0]["captureId"])) + self.admit_on(plan, dropped, repo) + # It goes before new lifecycle work, whose edit would rewrite the policy's key. + newer = self.capture(feeds=feeds, rows={**MAINTAINED, "8.4": ("eol", "31 Dec 2026"), "8.6": ("stable", "31 Dec 2030")}, + aggregate="8.6.0") + self.assertEqual(key, self.classify_on(newer, repo)["actionKey"]) + # A due patch still goes first. + due = self.capture(feeds={"8.5": "8.5.10"}, aggregate="8.5.10", rows={"8.5": MAINTAINED["8.5"]}) + self.assertEqual("new_patch:8.5.10", self.classify_on(due, repo)["actionKey"]) + # A policy that still maintains the branch retires it afresh, with its edit. + fresh = self.classify_on(manifest, self.base(("8.4", "8.5"), key)) + self.assertEqual((key, ["support-policy.json"]), (fresh["actionKey"], fresh["allowedPaths"]["php-bin"])) + # A retired newest branch php.net still calls supported is never resumed as retired. + newest = self.classify_on(self.capture(feeds={"8.4": "8.4.20"}), self.base(("8.4",), "branch_eol:8.5:2029-12-31")) + self.assertEqual("new_branch:8.5", newest["actionKey"]) + # A finished retirement, or a branch php.net calls supported, is not resumed. + finished = self.base(("8.5",), key, records=(key,)) + self.assertEqual("no_change", self.classify_on(dropped, finished)["action"]) + self.assertEqual( + "needs_human", self.classify_on(self.capture(feeds=feeds, rows={**MAINTAINED, "8.4": ("security", "31 Dec 2026")}), repo)["action"] + ) + # Admission rejects a retirement that is not fully on the base, or already recorded. + for reason, admit in { + "still maintains PHP 8.4": lambda: self.admit_on(plan, dropped, self.base(("8.4", "8.5"), key)), + f"policy was not written by {key}": lambda: self.admit_on( + plan, dropped, self.base(("8.5",), "branch_eol:8.3:2025-12-31")), + f"an event record for {key} already exists": lambda: self.admit_on(plan, dropped, finished), + }.items(): + with self.assertRaisesRegex(ControlError, reason): + admit() + def test_inconsistent_watcher_inputs_fail_the_job(self): manifest = self.capture() with self.assertRaisesRegex(ControlError, "preconditions"): @@ -497,12 +672,86 @@ def test_new_branch_copies_the_newest_module_list_and_seals(self): self.assertEqual("2026-09-28T00:00:00Z", policy["acceptedAt"]) sealed = seal_patch(self.repo, self.base, plan, self.tmp / "sealed") self.assertEqual(changed, [item["path"] for item in sealed["files"]]) + self.assertIs(False, sealed["alreadyApplied"]) # A reviewed module list already on main is never overwritten on a retry. subprocess.run(["git", "checkout", "-q", "--", "support-policy.json"], cwd=self.repo, check=True) (self.repo / f"expected-modules/{branch}.txt").write_text("reviewed\n") self.assertEqual(["support-policy.json"], apply_lifecycle_plan(self.repo, plan, json.loads(manifest.read_text()))) self.assertEqual("reviewed\n", (self.repo / f"expected-modules/{branch}.txt").read_text()) + def git(self, *argv): + return subprocess.run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@invalid", *argv], + cwd=self.repo, check=True, text=True, stdout=subprocess.PIPE).stdout.strip() + + def test_a_resumed_lifecycle_writes_nothing_and_seals_an_explicit_empty_patch(self): + # The fresh edit merges first, exactly as a run that then stopped would leave it. + newest = self.maintained[-1] + major, minor = newest.split(".") + branch = f"{major}.{int(minor) + 1}" + rows = {item: ("stable", "31 Dec 2029") for item in self.maintained} + rows[branch] = ("stable", "31 Dec 2031") + fresh, manifest = self.lifecycle_plan(rows, f"{branch}.0") + apply_lifecycle_plan(self.repo, fresh, json.loads(manifest.read_text())) + (self.repo / "autorelease-events").mkdir() + (self.repo / "autorelease-events/.keep").write_text("") + self.git("add", "-A") + self.git("commit", "-q", "-m", f"chore: {fresh['actionKey']}") + merged = self.git("rev-parse", "HEAD") + maintained = [*self.maintained, branch] + feeds = {item: f"{item}.1" for item in self.maintained} + feeds[branch] = f"{branch}.0" + capture = fixture_capture(self.tmp / "resume", branch_feeds=feeds, aggregate=f"{branch}.0", page=page(rows), + releases=[{"tag_name": f"{item}.1"} for item in self.maintained]) + preconditions = {**PRECONDITIONS, "phpBinHead": merged} + plan = classify_evidence(capture, preconditions, [], maintained, fresh["actionKey"]) + self.assertEqual({"php-bin": [], "mise-php": []}, plan["allowedPaths"]) + + self.assertEqual([], apply_lifecycle_plan(self.repo, plan, json.loads(capture.read_text()))) + self.assertEqual("", self.git("status", "--porcelain")) + sealed = seal_patch(self.repo, merged, plan, self.tmp / "sealed") + self.assertEqual(([], True), (sealed["files"], sealed["alreadyApplied"])) + self.assertEqual(0, (self.tmp / "sealed/sealed.patch").stat().st_size) + self.assertEqual(sha256_bytes(b""), sealed["patchDigest"]) + + # Nothing merges: the validated commit is the sealed base and must still be main. + checks = {"Script checks": "success"} + on_main = {"phpBinHead": merged, "supportPolicyDigest": "sha256:" + "d" * 64} + self.assertTrue(verify_merge(self.repo, merged, sealed, checks, on_main, on_main)["admitted"]) + moved = {**on_main, "phpBinHead": "0" * 40} + for reason, arguments in { + "merge preconditions changed": (sealed, on_main, moved), + "main is not the validated commit": (sealed, moved, moved), + "cannot seal file changes": ({**sealed, "files": [{"path": "support-policy.json"}]}, on_main, on_main), + "must be an already-applied lifecycle": ({**sealed, "alreadyApplied": False}, on_main, on_main), + }.items(): + with self.assertRaisesRegex(ControlError, reason): + verify_merge(self.repo, merged, arguments[0], checks, arguments[1], arguments[2]) + (self.repo / "later.txt").write_text("later\n") + self.git("add", "later.txt") + self.git("commit", "-q", "-m", "later") + later = self.git("rev-parse", "HEAD") + with self.assertRaisesRegex(ControlError, "sealed base itself"): + verify_merge(self.repo, later, sealed, checks, {**on_main, "phpBinHead": later}, {**on_main, "phpBinHead": later}) + self.git("reset", "-q", "--hard", merged) + + # A resume never edits: any change is unadmitted, and a record ends it. + (self.repo / f"expected-modules/{branch}.txt").write_text("edited\n") + with self.assertRaisesRegex(ControlError, "unadmitted path"): + seal_patch(self.repo, merged, plan, self.tmp / "sealed-edit") + self.git("checkout", "-q", "--", ".") + (self.repo / f"autorelease-events/new_branch-{branch}.json").write_text( + json.dumps({"actionKey": plan["actionKey"], "state": "php_bin_ready"})) + self.git("add", "-A") + self.git("commit", "-q", "-m", "record") + recorded = self.git("rev-parse", "HEAD") + for attempt in (lambda: apply_lifecycle_plan(self.repo, plan, json.loads(capture.read_text())), + lambda: seal_patch(self.repo, recorded, plan, self.tmp / "sealed-recorded")): + with self.assertRaisesRegex(ControlError, "already exists"): + attempt() + # A fresh plan that finds nothing to write is still no patch at all. + with self.assertRaisesRegex(ControlError, "produced no patch"): + seal_patch(self.repo, recorded, fresh, self.tmp / "sealed-fresh") + def test_branch_eol_only_removes_the_branch_from_the_policy(self): oldest = self.maintained[0] rows = {item: ("stable", "31 Dec 2029") for item in self.maintained}