diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb5b032..5690ff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI on: pull_request: + paths-ignore: + - maintenance-state/** + - maintenance-events/** workflow_dispatch: push: branches: diff --git a/.github/workflows/maintenance-release.yml b/.github/workflows/maintenance-release.yml index fd39dea..a27f926 100644 --- a/.github/workflows/maintenance-release.yml +++ b/.github/workflows/maintenance-release.yml @@ -87,12 +87,10 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | ./scripts/capture-maintenance-evidence --output release-run/evidence - while IFS= read -r encoded; do - capture_id="$(printf '%s' "$encoded" | base64 --decode | jq -r .captureId)" - admitted_digest="$(printf '%s' "$encoded" | base64 --decode | jq -r .digest)" - current_digest="$(jq -r --arg id "$capture_id" '.captures[] | select(.captureId==$id) | .digest' release-run/evidence/evidence-manifest.json)" - test "$current_digest" = "$admitted_digest" - done < <(jq -r '.evidence[] | @base64' admitted-run/maintenance-plan.json) + ./maintenance/control.py validate-recaptured-evidence \ + --plan admitted-run/maintenance-plan.json \ + --admitted-manifest admitted-run/evidence/evidence-manifest.json \ + --current-manifest release-run/evidence/evidence-manifest.json - name: Verify cross-repository readiness when required env: GH_TOKEN: ${{ github.token }} @@ -275,9 +273,7 @@ jobs: mise install "php@$branch" mise exec "php@${{ inputs.version }}" -- php -v mise exec "php@$branch" -- php -v - - name: Complete durable event and notify owner - env: - GH_TOKEN: ${{ github.token }} + - name: Complete durable event run: | jq -n --arg version "${{ inputs.version }}" --argjson assets "$(jq .assetDigests release-run/transaction.json)" \ '[{kind:"published_release",version:$version,assetDigests:$assets}]' > release-run/evidence.json @@ -295,13 +291,6 @@ jobs: mv release-run/next-event.json release-run/event.json jq '.severity="info" | .summary="PHP ${{ inputs.version }} was published and verified through fresh exact and branch-shorthand mise installs." | .finalResult="passed"' \ release-run/event.json > release-run/notification-event.json - ./scripts/notify-maintenance \ - --event release-run/notification-event.json \ - --state release-run/notification-state.json \ - --output release-run/notification-next.json \ - --backend github \ - --repo "${{ github.repository }}" \ - --owner "${{ vars.MAINTENANCE_OWNER }}" - name: Commit final event record through a checked PR id: event_pr env: @@ -345,6 +334,39 @@ jobs: test "$(git diff --name-only "${{ steps.event_pr.outputs.base_sha }}" "$actual")" = "${{ steps.event_pr.outputs.record }}" test "sha256:$(git show "$actual:${{ steps.event_pr.outputs.record }}" | shasum -a 256 | awk '{print $1}')" = "${{ steps.event_pr.outputs.digest }}" gh pr merge "${{ steps.event_pr.outputs.number }}" --squash --delete-branch + - name: Notify owner of completed release + env: + GH_TOKEN: ${{ github.token }} + run: | + ./scripts/notify-maintenance \ + --event release-run/notification-event.json \ + --state release-run/notification-state.json \ + --output release-run/notification-next.json \ + --backend github \ + --repo "${{ github.repository }}" \ + --owner "${{ vars.MAINTENANCE_OWNER }}" + - name: Record release failure diagnostic + if: failure() + env: + ACTION_KEY: ${{ inputs.action_key }} + VERSION: ${{ inputs.version }} + EXACT_COMMIT: ${{ inputs.exact_commit }} + INVESTIGATION_RUN_ID: ${{ inputs.investigation_run_id }} + run: | + [[ "$ACTION_KEY" =~ ^[A-Za-z0-9._:-]+$ ]] + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]] + [[ "$EXACT_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$INVESTIGATION_RUN_ID" =~ ^[1-9][0-9]*$ ]] + mkdir -p release-run + jq -n \ + --arg actionKey "$ACTION_KEY" \ + --arg version "$VERSION" \ + --arg exactCommit "$EXACT_COMMIT" \ + --arg investigationRunId "$INVESTIGATION_RUN_ID" \ + --arg releaseRunId "${{ github.run_id }}" \ + --arg runUrl "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + '{schemaVersion:1,actionKey:$actionKey,version:$version,exactCommit:$exactCommit,investigationRunId:$investigationRunId,releaseRunId:$releaseRunId,state:"failed",runUrl:$runUrl}' \ + > release-run/failure.json - name: Retain transaction and exact staging bytes if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -356,3 +378,46 @@ jobs: if-no-files-found: error retention-days: 90 include-hidden-files: true + + notify-failure: + name: Notify actionable release failure + needs: [release] + if: always() && needs.release.result == 'failure' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Create structured critical event + env: + ACTION_KEY: ${{ inputs.action_key }} + VERSION: ${{ inputs.version }} + EXACT_COMMIT: ${{ inputs.exact_commit }} + INVESTIGATION_RUN_ID: ${{ inputs.investigation_run_id }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + [[ "$ACTION_KEY" =~ ^[A-Za-z0-9._:-]+$ ]] + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?$ ]] + [[ "$EXACT_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$INVESTIGATION_RUN_ID" =~ ^[1-9][0-9]*$ ]] + jq -n \ + --arg actionKey "$ACTION_KEY" \ + --arg summary "Guarded release failed for PHP $VERSION: $RUN_URL" \ + --arg failureFingerprint "release-failure:$ACTION_KEY" \ + '{actionKey:$actionKey,state:"blocked",severity:"critical",humanActionRequired:false,summary:$summary,failureFingerprint:$failureFingerprint}' \ + > event.json + - name: Notify owner + env: + GH_TOKEN: ${{ github.token }} + run: | + ./scripts/notify-maintenance \ + --event event.json \ + --state notification-state.json \ + --output notification.json \ + --backend github \ + --repo "${{ github.repository }}" \ + --owner "${{ vars.MAINTENANCE_OWNER }}" diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index 7d03fcf..08ddf27 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -2,6 +2,9 @@ name: Protected controls on: pull_request_target: + paths-ignore: + - maintenance-state/** + - maintenance-events/** workflow_dispatch: inputs: pr_number: @@ -73,6 +76,7 @@ jobs: from maintenance.control import ( ControlError, + validate_completed_event_record, validate_evidence_attestation_predicate, validate_evidence_state_record, ) @@ -218,6 +222,44 @@ jobs: print("No attestation matched the exact watcher run and evidence state.", file=sys.stderr) raise SystemExit(1) + event_run = re.fullmatch(r"maintenance/(event|eol-complete)-(\d+)", head_ref) + if ( + len(protected) == 1 + and re.fullmatch(r"maintenance-events/[A-Za-z0-9._-]+\.json", protected[0]) + and event_run + and author == "github-actions[bot]" + and head_repo.lower() == repo.lower() + ): + commit = api_one(f"repos/{repo}/commits/{head}") + run = api_one(f"repos/{repo}/actions/runs/{event_run.group(2)}") + content = api_one(f"repos/{repo}/contents/{protected[0]}?ref={head}") + try: + decoded = base64.b64decode(content["content"].replace("\n", ""), validate=True) + record = json.loads(decoded) + validate_completed_event_record(record) + except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error: + print(f"Invalid completed maintenance event: {error}", file=sys.stderr) + raise SystemExit(1) from error + expected_filename = record["actionKey"].translate(str.maketrans({":": "-", "/": "-"})) + ".json" + expected_workflow = ( + ".github/workflows/maintenance-release.yml" + if event_run.group(1) == "event" + else ".github/workflows/maintenance-watch.yml" + ) + allowed_events = {"workflow_dispatch"} if event_run.group(1) == "event" else {"schedule", "workflow_dispatch"} + direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base] + trusted_run = ( + protected[0] == f"maintenance-events/{expected_filename}" + and run.get("path") == expected_workflow + and run.get("event") in allowed_events + and run.get("head_branch") == "main" + and run.get("head_sha") == base + and run.get("status") == "in_progress" + ) + if direct_parent and trusted_run: + print(f"Protected completed event approved from trusted run {run['id']}.") + raise SystemExit(0) + reviews = api(f"repos/{repo}/pulls/{number}/reviews") approved = any( review.get("state") == "APPROVED" diff --git a/maintenance/control.py b/maintenance/control.py index 1a5bcd0..1d090a1 100755 --- a/maintenance/control.py +++ b/maintenance/control.py @@ -282,6 +282,92 @@ def validate_stable_release_evidence( ) +def validate_recaptured_evidence( + plan: dict[str, Any], + admitted_manifest: dict[str, Any], + current_manifest: dict[str, Any], +) -> dict[str, Any]: + """Verify cited authoritative captures while allowing runtime-only evidence.""" + + def indexed_captures(manifest: dict[str, Any], label: str) -> dict[str, dict[str, Any]]: + require(isinstance(manifest, dict), f"{label} evidence manifest must be an object") + require(manifest.get("schemaVersion") == 1, f"{label} evidence manifest version is invalid") + captures = manifest.get("captures") + require(isinstance(captures, list), f"{label} evidence captures must be an array") + indexed: dict[str, dict[str, Any]] = {} + comparable = [] + for capture in captures: + require(isinstance(capture, dict), f"{label} evidence capture must be an object") + capture_id = capture.get("captureId") + digest = capture.get("digest") + require(capture_id in EVIDENCE_CAPTURE_IDS, f"{label} evidence capture is unknown") + require(capture_id not in indexed, f"{label} evidence capture is duplicated: {capture_id}") + require(capture.get("status") == 200, f"{label} evidence capture is not healthy: {capture_id}") + require(bool(SHA256_RE.fullmatch(digest or "")), f"{label} evidence digest is invalid: {capture_id}") + indexed[capture_id] = capture + comparable.append({"captureId": capture_id, "status": capture["status"], "digest": digest}) + require(set(indexed) == EVIDENCE_CAPTURE_IDS, f"{label} evidence capture set changed") + require( + manifest.get("manifestDigest") == sha256_bytes(canonical_json(comparable)), + f"{label} evidence manifest digest mismatch", + ) + return indexed + + admitted = indexed_captures(admitted_manifest, "admitted") + current = indexed_captures(current_manifest, "current") + evidence = plan.get("evidence") + require(isinstance(evidence, list) and bool(evidence), "maintenance plan has no evidence") + verified = [] + for item in evidence: + require(isinstance(item, dict), "plan evidence entry must be an object") + capture_id = item.get("captureId") + digest = item.get("digest") + require(bool(SHA256_RE.fullmatch(digest or "")), f"plan evidence digest is invalid: {capture_id}") + if capture_id in RUNTIME_PLAN_EVIDENCE_IDS: + continue + require(capture_id in admitted, f"plan evidence capture is unknown: {capture_id}") + require(admitted[capture_id]["digest"] == digest, f"admitted evidence digest mismatch: {capture_id}") + require(current[capture_id]["digest"] == digest, f"recaptured evidence changed: {capture_id}") + verified.append(capture_id) + require(bool(verified), "maintenance plan cites no authoritative captured evidence") + return {"valid": True, "verifiedCaptureIds": sorted(verified)} + + +def validate_completed_event_record(record: dict[str, Any]) -> None: + """Validate a durable event as a complete, contiguous legal transition history.""" + + require(isinstance(record, dict), "maintenance event must be an object") + require(record.get("schemaVersion") == 1, "maintenance event version is invalid") + require(bool(ACTION_KEY_RE.fullmatch(record.get("actionKey", ""))), "maintenance event action key is invalid") + require(record.get("state") == "complete", "maintenance event is not complete") + history = record.get("history") + require(isinstance(history, list) and bool(history), "maintenance event has no transition history") + current = history[0].get("from") if isinstance(history[0], dict) else None + for transition in history: + require(isinstance(transition, dict), "maintenance event transition must be an object") + require( + set(transition) == {"from", "to", "at", "evidence"}, + "maintenance event transition fields changed", + ) + require(transition.get("from") == current, "maintenance event history is not contiguous") + target = transition.get("to") + require(target in LEGAL_EVENT_TRANSITIONS.get(current, set()), "maintenance event transition is illegal") + timestamp = transition.get("at") + require( + isinstance(timestamp, str) and timestamp.endswith("Z"), + "maintenance event transition timestamp is invalid", + ) + evidence = transition.get("evidence") + require( + isinstance(evidence, list) + and bool(evidence) + and all(isinstance(item, dict) and bool(item) for item in evidence), + "maintenance event transition evidence is invalid", + ) + current = target + require(current == record["state"], "maintenance event state does not match its history") + + def validate_evidence_state_record(record: dict[str, Any]) -> None: require(isinstance(record, dict), "evidence state must be an object") require( @@ -1127,6 +1213,11 @@ def main(argv: list[str] | None = None) -> int: capture_parser = subparsers.add_parser("capture-evidence") capture_parser.add_argument("--output", required=True, type=pathlib.Path) + recapture_parser = subparsers.add_parser("validate-recaptured-evidence") + recapture_parser.add_argument("--plan", required=True, type=pathlib.Path) + recapture_parser.add_argument("--admitted-manifest", required=True, type=pathlib.Path) + recapture_parser.add_argument("--current-manifest", required=True, type=pathlib.Path) + event_parser = subparsers.add_parser("transition-event") event_parser.add_argument("--event", required=True, type=pathlib.Path) event_parser.add_argument("--target", required=True) @@ -1150,6 +1241,16 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"valid": True})) elif args.command == "capture-evidence": print(json.dumps(capture_evidence(args.output, token=os.environ.get("GITHUB_TOKEN")))) + elif args.command == "validate-recaptured-evidence": + print( + json.dumps( + validate_recaptured_evidence( + load_json(args.plan), + load_json(args.admitted_manifest), + load_json(args.current_manifest), + ) + ) + ) elif args.command == "transition-event": updated = transition_event(load_json(args.event), args.target, load_json(args.evidence)) write_json(args.output, updated) diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 08942ac..d61cc71 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -24,8 +24,10 @@ transition_event, validate_archive, validate_completion_assessment, + validate_completed_event_record, validate_evidence_attestation_predicate, validate_evidence_state_record, + validate_recaptured_evidence, validate_stable_release_evidence, verify_merge, watch_decision, @@ -215,6 +217,59 @@ def test_source_tag_alone_cannot_admit_a_stable_release(self): [*tag_only, {"captureId": "php_release_feed", "value": "8.5.9"}], ) + def test_release_recapture_ignores_runtime_evidence_and_verifies_sources(self): + capture_ids = sorted( + { + "php_supported_versions", + "php_release_feed", + "php_source_tags", + "php_bin_releases", + "php_bin_state", + "mise_php_releases", + "mise_php_state", + } + ) + captures = [ + {"captureId": capture_id, "status": 200, "digest": "sha256:" + f"{index:064x}"} + for index, capture_id in enumerate(capture_ids, start=1) + ] + manifest = { + "schemaVersion": 1, + "captures": captures, + "manifestDigest": sha256_bytes( + canonical_json( + [ + {"captureId": item["captureId"], "status": item["status"], "digest": item["digest"]} + for item in captures + ] + ) + ), + } + plan = { + "evidence": [ + {"captureId": item["captureId"], "digest": item["digest"]} for item in captures + ] + + [ + {"captureId": "watch_decision", "digest": "sha256:" + "a" * 64}, + {"captureId": "evidence_manifest", "digest": "sha256:" + "b" * 64}, + ] + } + result = validate_recaptured_evidence(plan, manifest, manifest) + self.assertEqual(capture_ids, result["verifiedCaptureIds"]) + + changed = json.loads(json.dumps(manifest)) + changed["captures"][0]["digest"] = "sha256:" + "f" * 64 + changed["manifestDigest"] = sha256_bytes( + canonical_json( + [ + {"captureId": item["captureId"], "status": item["status"], "digest": item["digest"]} + for item in changed["captures"] + ] + ) + ) + with self.assertRaisesRegex(ControlError, "recaptured evidence changed"): + validate_recaptured_evidence(plan, manifest, changed) + def test_runtime_plan_evidence_is_exact_and_allowlisted(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) @@ -233,6 +288,37 @@ def test_illegal_event_transition_fails_closed(self): with self.assertRaises(ControlError): transition_event({"state": "detected"}, "complete", [{"digest": "x"}]) + def test_completed_event_record_requires_contiguous_legal_evidenced_history(self): + record = { + "schemaVersion": 1, + "actionKey": "new_patch:8.5.9", + "state": "complete", + "history": [ + { + "from": "release_requested", + "to": "released", + "at": "2026-07-31T10:00:00Z", + "evidence": [{"kind": "published_release"}], + }, + { + "from": "released", + "to": "public_install_verified", + "at": "2026-07-31T10:01:00Z", + "evidence": [{"kind": "fresh_public_install"}], + }, + { + "from": "public_install_verified", + "to": "complete", + "at": "2026-07-31T10:02:00Z", + "evidence": [{"kind": "transaction_complete"}], + }, + ], + } + validate_completed_event_record(record) + record["history"][1]["from"] = "detected" + with self.assertRaisesRegex(ControlError, "not contiguous"): + validate_completed_event_record(record) + def test_published_asset_mismatch_fails_closed(self): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) @@ -297,6 +383,12 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): dispatcher = (root / "scripts/dispatch-pr-checks").read_text() self.assertIn("workflow_dispatch:", ci) self.assertIn("workflow_dispatch:", protected) + self.assertIn("paths-ignore:", ci) + self.assertIn("maintenance-state/**", ci) + self.assertIn("paths-ignore:", protected) + self.assertIn("maintenance-events/**", protected) + self.assertIn("validate_completed_event_record", protected) + self.assertIn('maintenance/(event|eol-complete)-', protected) self.assertIn("pr_number:", protected) self.assertIn("gh workflow run ci.yml", dispatcher) self.assertIn("gh workflow run protected-controls.yml", dispatcher) @@ -314,6 +406,15 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): self.assertIn("checks: write", body) self.assertIn("statuses: write", body) + release = (root / ".github/workflows/maintenance-release.yml").read_text() + self.assertIn("validate-recaptured-evidence", release) + self.assertIn("Notify actionable release failure", release) + self.assertIn("release-run/failure.json", release) + self.assertLess( + release.index("Validate and merge final event record"), + release.index("Notify owner of completed release"), + ) + def test_malformed_contract_shapes_fail_closed(self): contract = self._contract() contract["allowedAuthority"] = [[]]