From f6061e9de9e539cfb7cf93fffbb5061bc7c0a9ef Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 15:45:19 +0300 Subject: [PATCH 1/7] fix: constrain assessment evidence references --- .github/codex/maintenance/investigation.md | 7 +++++++ maintenance/control.py | 11 ++++++++++- schemas/maintenance-plan.schema.json | 16 ++++++++++++---- scripts/validate-structured-output-schemas | 21 ++++++++++++++++++++- tests/test_maintenance.py | 12 ++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/.github/codex/maintenance/investigation.md b/.github/codex/maintenance/investigation.md index 391a1a0..440e3b4 100644 --- a/.github/codex/maintenance/investigation.md +++ b/.github/codex/maintenance/investigation.md @@ -41,3 +41,10 @@ forms enforced by the output schema: `no_change`, `new_patch`, `new_branch`, `branch_eol`, `recipe_rebuild`, `repair`, `source_unhealthy`, `health_failed`, `policy_failure`, or `auth_failure` with the required version, date, attempt, or lowercase hexadecimal evidence suffix. + +Every `completionAssessment.criteria[].evidence` entry is a machine-resolved +reference, never explanatory prose. Use only `evidence[N]` for an item in the +plan evidence array, `preconditions.phpBinHead`, `preconditions.misePhpHead`, +`preconditions.supportPolicyDigest`, or `researchSources[N]` for an item in the +research source array. Put explanations in the criterion status or plan summary, +not in an evidence-reference array. diff --git a/maintenance/control.py b/maintenance/control.py index f6cdef6..22f957d 100755 --- a/maintenance/control.py +++ b/maintenance/control.py @@ -38,6 +38,11 @@ r"repair:\d+\.\d+\.\d+:[0-9a-f]{8,64}|" r"(?:source_unhealthy|health_failed|policy_failure|auth_failure):[0-9a-f]{8,64})$" ) +COMPLETION_EVIDENCE_REF_RE = re.compile( + r"^(evidence\[\d+\]|preconditions\.(?:phpBinHead|misePhpHead|supportPolicyDigest)|" + r"researchSources\[\d+\])$" +) +REQUIRED_PLAN_CHECKS = ["Script checks"] STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") try: @@ -466,6 +471,10 @@ def validate_plan( source_refs = {f"researchSources[{index}]" for index in range(len(research_sources))} for result in plan["completionAssessment"]["criteria"]: for reference in result["evidence"]: + require( + bool(COMPLETION_EVIDENCE_REF_RE.fullmatch(reference)), + f"invalid criterion evidence reference: {reference}", + ) require( reference in evidence_refs or reference in precondition_refs @@ -493,7 +502,7 @@ def validate_plan( and all(value in {"php-bin", "mise-php"} for value in repositories), "plan repository authority is invalid", ) - require(plan.get("requiredChecks") == ["Script checks"], "required deterministic checks changed") + require(plan.get("requiredChecks") == REQUIRED_PLAN_CHECKS, "required deterministic checks changed") release_intent = plan.get("releaseIntent") if release_intent is not None: require(isinstance(release_intent, dict), "releaseIntent must be an object or null") diff --git a/schemas/maintenance-plan.schema.json b/schemas/maintenance-plan.schema.json index a3357b8..c056c71 100644 --- a/schemas/maintenance-plan.schema.json +++ b/schemas/maintenance-plan.schema.json @@ -52,7 +52,7 @@ } }, "researchSources": {"type": "array", "items": {"type": "string"}}, - "repositories": {"type": "array", "items": {"type": "string", "enum": ["php-bin", "mise-php"]}}, + "repositories": {"type": "array", "minItems": 1, "maxItems": 2, "items": {"type": "string", "enum": ["php-bin", "mise-php"]}}, "preconditions": { "type": "object", "additionalProperties": false, @@ -73,7 +73,7 @@ "mise-php": {"type": "array", "items": {"type": "string"}} } }, - "requiredChecks": {"type": "array", "items": {"type": "string"}}, + "requiredChecks": {"type": "array", "items": {"type": "string"}, "const": ["Script checks"]}, "releaseIntent": { "type": ["object", "null"], "additionalProperties": false, @@ -124,14 +124,22 @@ "phaseStatus": {"type": "string", "enum": ["complete", "blocked", "needs_human"]}, "criteria": { "type": "array", + "minItems": 4, + "maxItems": 4, "items": { "type": "object", "additionalProperties": false, "required": ["id", "status", "evidence"], "properties": { - "id": {"type": "string"}, + "id": {"type": "string", "enum": ["phase-goal-correct", "evidence-classification-complete", "preconditions-and-authority-explicit", "no-unresolved-investigation-work"]}, "status": {"type": "string", "enum": ["passed", "failed", "unresolved"]}, - "evidence": {"type": "array", "items": {"type": "string"}} + "evidence": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(evidence\\[\\d+\\]|preconditions\\.(?:phpBinHead|misePhpHead|supportPolicyDigest)|researchSources\\[\\d+\\])$" + } + } } } }, diff --git a/scripts/validate-structured-output-schemas b/scripts/validate-structured-output-schemas index 0eecb2c..44a2094 100755 --- a/scripts/validate-structured-output-schemas +++ b/scripts/validate-structured-output-schemas @@ -15,7 +15,11 @@ OUTPUT_SCHEMA_RE = re.compile(r'--output-schema","([^"]+\.json)"') UNSUPPORTED_KEYWORDS = {"uniqueItems"} sys.path.insert(0, str(ROOT)) -from maintenance.control import ACTION_KEY_RE # noqa: E402 +from maintenance.control import ( # noqa: E402 + ACTION_KEY_RE, + COMPLETION_EVIDENCE_REF_RE, + REQUIRED_PLAN_CHECKS, +) def fail(message: str) -> None: @@ -77,6 +81,21 @@ def main() -> int: schema_pattern = document.get("properties", {}).get("actionKey", {}).get("pattern") if schema_pattern != ACTION_KEY_RE.pattern: fail("maintenance plan actionKey pattern must match deterministic admission") + properties = document.get("properties", {}) + if properties.get("requiredChecks", {}).get("const") != REQUIRED_PLAN_CHECKS: + fail("maintenance plan requiredChecks must match deterministic admission") + evidence_pattern = ( + properties.get("completionAssessment", {}) + .get("properties", {}) + .get("criteria", {}) + .get("items", {}) + .get("properties", {}) + .get("evidence", {}) + .get("items", {}) + .get("pattern") + ) + if evidence_pattern != COMPLETION_EVIDENCE_REF_RE.pattern: + fail("criterion evidence reference pattern must match deterministic admission") print(f"Validated {len(schema_paths)} Codex Structured Outputs schemas.") return 0 diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index fb29933..5f7cd53 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -9,6 +9,7 @@ from unittest import mock from maintenance.control import ( + COMPLETION_EVIDENCE_REF_RE, ControlError, canonical_json, mutation_allowed, @@ -96,6 +97,17 @@ def test_completion_go_is_mechanical(self): with self.assertRaises(ControlError): validate_completion_assessment(assessment, contract, digests) + def test_investigation_evidence_references_are_machine_resolvable(self): + for reference in ( + "evidence[0]", + "preconditions.phpBinHead", + "preconditions.misePhpHead", + "preconditions.supportPolicyDigest", + "researchSources[2]", + ): + self.assertIsNotNone(COMPLETION_EVIDENCE_REF_RE.fullmatch(reference)) + self.assertIsNone(COMPLETION_EVIDENCE_REF_RE.fullmatch("watch-decision.json reports success")) + def test_illegal_event_transition_fails_closed(self): with self.assertRaises(ControlError): transition_event({"state": "detected"}, "complete", [{"digest": "x"}]) From be2471cc203eb177a30ea64e686de25248cb1504 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 15:50:47 +0300 Subject: [PATCH 2/7] fix: admit deterministic evidence state --- .github/workflows/protected-controls.yml | 52 ++++++++++++++++++++++++ docs/repository-settings.md | 11 ++++- maintenance/control.py | 31 ++++++++++++++ tests/test_maintenance.py | 25 ++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index 0b02dcb..e019da7 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -23,16 +23,24 @@ jobs: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PROTECTED_REVIEWER: ${{ vars.MAINTENANCE_OWNER }} run: | python3 - <<'PY' import fnmatch + import base64 import json import os import pathlib + import re import subprocess import sys + from maintenance.control import ControlError, validate_evidence_state_record + def api(path): result = subprocess.run( ["gh", "api", path, "--paginate", "--slurp"], @@ -45,9 +53,22 @@ jobs: raise RuntimeError("GitHub API returned an invalid paginated response") return [item for page in pages for item in page] + def api_one(path): + result = subprocess.run( + ["gh", "api", path], + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + repo = os.environ["REPOSITORY"] number = os.environ["PR_NUMBER"] head = os.environ["HEAD_SHA"] + base = os.environ["BASE_SHA"] + head_ref = os.environ["HEAD_REF"] + head_repo = os.environ["HEAD_REPOSITORY"] + author = os.environ["PR_AUTHOR"] reviewer = os.environ["PROTECTED_REVIEWER"].lower() manifest = json.loads(pathlib.Path("maintenance/protected-paths.json").read_text()) patterns = manifest["patterns"] @@ -61,6 +82,37 @@ jobs: print("No protected control path changed.") raise SystemExit(0) + evidence_run = re.fullmatch(r"maintenance/evidence-(\d+)", head_ref) + if ( + protected == ["maintenance-state/last-evidence.json"] + and evidence_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/{evidence_run.group(1)}") + content = api_one( + f"repos/{repo}/contents/maintenance-state/last-evidence.json?ref={head}" + ) + try: + encoded = content["content"].replace("\n", "") + record = json.loads(base64.b64decode(encoded, validate=True)) + validate_evidence_state_record(record) + except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error: + print(f"Invalid deterministic evidence state: {error}", file=sys.stderr) + raise SystemExit(1) from error + direct_parent = [parent.get("sha") for parent in commit.get("parents", [])] == [base] + trusted_run = ( + run.get("path") == ".github/workflows/maintenance-watch.yml" + and run.get("event") in {"schedule", "workflow_dispatch"} + 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 deterministic evidence state approved from watcher run {run['id']}.") + raise SystemExit(0) + reviews = api(f"repos/{repo}/pulls/{number}/reviews") approved = any( review.get("state") == "APPROVED" diff --git a/docs/repository-settings.md b/docs/repository-settings.md index 6dffcaa..b4a3b00 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -11,7 +11,12 @@ Required repository state: - Require the `Script checks` 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 paths in `maintenance/protected-paths.json`. + The sole deterministic exception is `maintenance-state/last-evidence.json`: + a same-repository `github-actions[bot]` PR may pass only when it is a direct + child of the current base, is tied to the still-running protected watcher, + changes exactly that file, and the record has the reviewed healthy-capture + shape. Runtime Codex cannot invoke this exception or edit that state. - Bind the required check to the GitHub Actions app, preventing another app from satisfying the same context name. - Require conversation resolution. @@ -23,7 +28,9 @@ Required repository state: - Keep the default Actions token read-only while enabling automation PR creation. Runtime Codex jobs declare read scopes; only deterministic downstream jobs explicitly declare the write scopes they require. -- Do not allow the workflow token to approve pull requests. +- Enable the organization setting that permits Actions to create pull requests; + runtime workflows do not submit approving reviews. Protected-control approval + remains owner-only except for the deterministic evidence-state proof above. - Allow GitHub-owned Actions plus only `openai/codex-action` and `jdx/mise-action`, and require every Action reference to use a full commit SHA. diff --git a/maintenance/control.py b/maintenance/control.py index 22f957d..1f7f7be 100755 --- a/maintenance/control.py +++ b/maintenance/control.py @@ -43,6 +43,15 @@ r"researchSources\[\d+\])$" ) REQUIRED_PLAN_CHECKS = ["Script checks"] +EVIDENCE_CAPTURE_IDS = { + "php_supported_versions", + "php_release_feed", + "php_source_tags", + "php_bin_releases", + "php_bin_state", + "mise_php_releases", + "mise_php_state", +} STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[1-9]\d*)?$") PROTECTED_PATHS = pathlib.Path(__file__).with_name("protected-paths.json") try: @@ -253,6 +262,28 @@ def resolve_json_pointer(document: Any, pointer: str) -> Any: return current +def validate_evidence_state_record(record: dict[str, Any]) -> None: + require(isinstance(record, dict), "evidence state must be an object") + require( + set(record) == {"schemaVersion", "manifestDigest", "planDigest", "captures"}, + "evidence state fields changed", + ) + require(record.get("schemaVersion") == 1, "invalid evidence state version") + require(bool(SHA256_RE.fullmatch(record.get("manifestDigest", ""))), "invalid evidence manifest digest") + require(bool(SHA256_RE.fullmatch(record.get("planDigest", ""))), "invalid evidence plan digest") + captures = record.get("captures") + require(isinstance(captures, list), "evidence captures must be an array") + capture_ids = [] + for capture in captures: + require(isinstance(capture, dict), "evidence capture must be an object") + require(set(capture) == {"captureId", "digest", "status"}, "evidence capture fields changed") + capture_ids.append(capture.get("captureId")) + require(bool(SHA256_RE.fullmatch(capture.get("digest", ""))), "invalid evidence capture digest") + require(capture.get("status") == 200, "evidence capture status is not healthy") + require(len(capture_ids) == len(set(capture_ids)), "duplicate evidence capture") + require(set(capture_ids) == EVIDENCE_CAPTURE_IDS, "evidence capture set changed") + + def load_capture(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: manifest = load_json(manifest_path) require(isinstance(manifest, dict), "capture manifest must be an object") diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 5f7cd53..27cf996 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -23,6 +23,7 @@ transition_event, validate_archive, validate_completion_assessment, + validate_evidence_state_record, verify_merge, watch_decision, path_is_protected, @@ -108,6 +109,30 @@ def test_investigation_evidence_references_are_machine_resolvable(self): self.assertIsNotNone(COMPLETION_EVIDENCE_REF_RE.fullmatch(reference)) self.assertIsNone(COMPLETION_EVIDENCE_REF_RE.fullmatch("watch-decision.json reports success")) + def test_deterministic_evidence_state_shape_is_fail_closed(self): + capture_ids = ( + "php_supported_versions", + "php_release_feed", + "php_source_tags", + "php_bin_releases", + "php_bin_state", + "mise_php_releases", + "mise_php_state", + ) + record = { + "schemaVersion": 1, + "manifestDigest": "sha256:" + "a" * 64, + "planDigest": "sha256:" + "b" * 64, + "captures": [ + {"captureId": capture_id, "digest": "sha256:" + "c" * 64, "status": 200} + for capture_id in capture_ids + ], + } + validate_evidence_state_record(record) + record["captures"][0]["status"] = 500 + with self.assertRaisesRegex(ControlError, "not healthy"): + validate_evidence_state_record(record) + def test_illegal_event_transition_fails_closed(self): with self.assertRaises(ControlError): transition_event({"state": "detected"}, "complete", [{"digest": "x"}]) From 79ca95765cf48b69fdc78b797d5f557add37d455 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 16:06:48 +0300 Subject: [PATCH 3/7] docs: refresh maintenance admin evidence --- docs/admin-state/php-bin-after.json | 6 +++--- docs/maintenance-admin-evidence.json | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/admin-state/php-bin-after.json b/docs/admin-state/php-bin-after.json index eedc6d2..e5b9c02 100644 --- a/docs/admin-state/php-bin-after.json +++ b/docs/admin-state/php-bin-after.json @@ -71,7 +71,7 @@ }, "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection" }, - "capturedAt": "2026-07-28T09:06:41Z", + "capturedAt": "2026-07-28T13:05:02Z", "environments": { "environments": [ { @@ -182,12 +182,12 @@ ], "verified_allowed": false }, - "snapshotDigest": "sha256:3919519486d0614af629c4fba227981a104ba2668e974deb7aecc3702b808eba", + "snapshotDigest": "sha256:3d0ed7f751c408e71033bdc593f7eeba2161470658277281d1c409a7b0572797", "variables": [ "MAINTENANCE_OWNER" ], "workflowPermissions": { - "can_approve_pull_request_reviews": false, + "can_approve_pull_request_reviews": true, "default_workflow_permissions": "read" } } diff --git a/docs/maintenance-admin-evidence.json b/docs/maintenance-admin-evidence.json index d141036..016d673 100644 --- a/docs/maintenance-admin-evidence.json +++ b/docs/maintenance-admin-evidence.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "capturedAt": "2026-07-27T10:18:59Z", + "capturedAt": "2026-07-28T13:05:56Z", "planExecutor": "OpenAI Codex under loadinglucian authorization", "repositories": { "php-bin": { @@ -10,7 +10,7 @@ }, "afterSnapshot": { "path": "docs/admin-state/php-bin-after.json", - "digest": "sha256:3919519486d0614af629c4fba227981a104ba2668e974deb7aecc3702b808eba" + "digest": "sha256:3d0ed7f751c408e71033bdc593f7eeba2161470658277281d1c409a7b0572797" }, "settingsPullRequests": [ "https://github.com/Bigpixelrocket/php-bin/pull/8", @@ -24,7 +24,7 @@ }, "afterSnapshot": { "path": "../mise-php/docs/admin-state/mise-php-after.json", - "digest": "sha256:c403c9c4c6abab5518aed00a89f5d89b3233921e6a49627e12c2b7d953da6086" + "digest": "sha256:e50672433148e1054cd0436af11f47cbc7bf643db650eaed166ffcd1633fa632" }, "settingsPullRequests": [ "https://github.com/Bigpixelrocket/mise-php/pull/9", @@ -61,6 +61,16 @@ }, "protectionRestored": true, "immutableReleasesEnabled": true, + "organizationWorkflowPermissions": { + "defaultWorkflowPermissions": "read", + "actionsCanCreateAndApprovePullRequests": true, + "runtimeWorkflowsSubmitApprovingReviews": false, + "verifiedThroughOrganizationSettingsAt": "2026-07-28T13:05:56Z", + "repositoryReadBackVerified": [ + "Bigpixelrocket/php-bin", + "Bigpixelrocket/mise-php" + ] + }, "releaseEnvironment": { "protectedBranchesOnly": true, "administratorBypass": false From 5cd31f14794a4710890ca89efd6bea289f959dd2 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 16:21:32 +0300 Subject: [PATCH 4/7] fix: attest watcher evidence provenance --- .github/maintenance-pins.json | 1 + .github/workflows/maintenance-watch.yml | 43 +++++++++++---- .github/workflows/protected-controls.yml | 68 ++++++++++++++++++++++-- docs/repository-settings.md | 5 +- maintenance/control.py | 28 ++++++++++ tests/test_maintenance.py | 20 +++++++ 6 files changed, 151 insertions(+), 14 deletions(-) diff --git a/.github/maintenance-pins.json b/.github/maintenance-pins.json index c95c383..266170c 100644 --- a/.github/maintenance-pins.json +++ b/.github/maintenance-pins.json @@ -4,6 +4,7 @@ "actions/checkout": "3d3c42e5aac5ba805825da76410c181273ba90b1", "actions/cache": "55cc8345863c7cc4c66a329aec7e433d2d1c52a9", "actions/download-artifact": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "actions/attest": "f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6", "actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", "jdx/mise-action": "9e7f7633ff6f6d6048a9418a68d48f288f50eb14", "openai/codex-action": "52fe01ec70a42f454c9d2ebd47598f9fd6893d56" diff --git a/.github/workflows/maintenance-watch.yml b/.github/workflows/maintenance-watch.yml index 2ee7052..108ab0e 100644 --- a/.github/workflows/maintenance-watch.yml +++ b/.github/workflows/maintenance-watch.yml @@ -139,7 +139,10 @@ jobs: timeout-minutes: 5 permissions: actions: write + artifact-metadata: write + attestations: write contents: write + id-token: write pull-requests: write issues: write steps: @@ -156,6 +159,37 @@ jobs: --repo "${{ github.repository }}" \ --name "maintenance-investigation-${{ github.run_id }}" \ --dir maintenance-plan-download + - name: Read unattended mutation state + id: operator + run: | + test "$(jq -r .unattendedMutation .github/maintenance-operator.json)" = "enabled" \ + && echo "enabled=true" >> "$GITHUB_OUTPUT" \ + || echo "enabled=false" >> "$GITHUB_OUTPUT" + - name: Prepare deterministic no-change evidence + if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' + run: | + git checkout -B "maintenance/evidence-${{ github.run_id }}" origin/main + mkdir -p maintenance-state + jq -n \ + --arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \ + --arg planDigest "$(jq -r .planDigest maintenance-plan-download/admission.json)" \ + --argjson captureDigests "$(jq '[.captures[] | {captureId,digest,status}]' maintenance-plan-download/evidence/evidence-manifest.json)" \ + '{schemaVersion:1,manifestDigest:$manifestDigest,planDigest:$planDigest,captures:$captureDigests}' \ + > maintenance-state/last-evidence.json + jq -n \ + --arg runId "${{ github.run_id }}" \ + --arg sourceSha "${{ needs.investigate.outputs.base_sha }}" \ + --arg actionKey "${{ needs.investigate.outputs.action_key }}" \ + --arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \ + '{schemaVersion:1,runId:$runId,sourceSha:$sourceSha,actionKey:$actionKey,manifestDigest:$manifestDigest}' \ + > maintenance-plan-download/evidence-attestation-predicate.json + - name: Attest deterministic no-change evidence + if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: maintenance-state/last-evidence.json + predicate-type: https://bigpixelrocket.dev/maintenance/evidence-state/v1 + predicate-path: maintenance-plan-download/evidence-attestation-predicate.json - name: Dispatch implementation or no-edit release env: GH_TOKEN: ${{ github.token }} @@ -198,16 +232,7 @@ jobs: fi if [[ "$action" == "no_change" ]]; then - : - git checkout -B "maintenance/evidence-${{ github.run_id }}" origin/main base="$(git rev-parse HEAD)" - mkdir -p maintenance-state - jq -n \ - --arg manifestDigest "$(jq -r .manifestDigest maintenance-plan-download/evidence/evidence-manifest.json)" \ - --arg planDigest "$(jq -r .planDigest maintenance-plan-download/admission.json)" \ - --argjson captureDigests "$(jq '[.captures[] | {captureId,digest,status}]' maintenance-plan-download/evidence/evidence-manifest.json)" \ - '{schemaVersion:1,manifestDigest:$manifestDigest,planDigest:$planDigest,captures:$captureDigests}' \ - > maintenance-state/last-evidence.json git add maintenance-state/last-evidence.json git -c user.name=maintenance-watcher -c user.email=maintenance@invalid \ commit -m "chore: record reviewed maintenance evidence" diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index e019da7..f602edb 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -4,6 +4,7 @@ on: pull_request_target: permissions: + attestations: read contents: read pull-requests: read @@ -38,8 +39,13 @@ jobs: import re import subprocess import sys + import tempfile - from maintenance.control import ControlError, validate_evidence_state_record + from maintenance.control import ( + ControlError, + validate_evidence_attestation_predicate, + validate_evidence_state_record, + ) def api(path): result = subprocess.run( @@ -96,7 +102,8 @@ jobs: ) try: encoded = content["content"].replace("\n", "") - record = json.loads(base64.b64decode(encoded, validate=True)) + decoded = base64.b64decode(encoded, validate=True) + record = json.loads(decoded) validate_evidence_state_record(record) except (KeyError, ValueError, json.JSONDecodeError, ControlError) as error: print(f"Invalid deterministic evidence state: {error}", file=sys.stderr) @@ -110,8 +117,61 @@ jobs: and run.get("status") == "in_progress" ) if direct_parent and trusted_run: - print(f"Protected deterministic evidence state approved from watcher run {run['id']}.") - raise SystemExit(0) + try: + with tempfile.NamedTemporaryFile() as evidence_file: + evidence_file.write(decoded) + evidence_file.flush() + verification = subprocess.run( + [ + "gh", + "attestation", + "verify", + evidence_file.name, + "--repo", + repo, + "--signer-workflow", + f"{repo}/.github/workflows/maintenance-watch.yml", + "--source-ref", + "refs/heads/main", + "--source-digest", + base, + "--predicate-type", + "https://bigpixelrocket.dev/maintenance/evidence-state/v1", + "--format", + "json", + ], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + attestations = json.loads(verification.stdout) + predicates = [ + item["verificationResult"]["statement"]["predicate"] + for item in attestations + ] + except ( + KeyError, + json.JSONDecodeError, + subprocess.CalledProcessError, + ) as error: + print(f"Evidence attestation verification failed: {error}", file=sys.stderr) + raise SystemExit(1) from error + for predicate in predicates: + try: + validate_evidence_attestation_predicate( + predicate, + run_id=evidence_run.group(1), + source_sha=base, + action_key=f"no_change:{record['manifestDigest'].removeprefix('sha256:')[:16]}", + manifest_digest=record["manifestDigest"], + ) + except ControlError: + continue + print(f"Protected deterministic evidence state approved from attested watcher run {run['id']}.") + raise SystemExit(0) + print("No attestation matched the exact watcher run and evidence state.", file=sys.stderr) + raise SystemExit(1) reviews = api(f"repos/{repo}/pulls/{number}/reviews") approved = any( diff --git a/docs/repository-settings.md b/docs/repository-settings.md index b4a3b00..75a0ec9 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -16,7 +16,10 @@ Required repository state: a same-repository `github-actions[bot]` PR may pass only when it is a direct child of the current base, is tied to the still-running protected watcher, changes exactly that file, and the record has the reviewed healthy-capture - shape. Runtime Codex cannot invoke this exception or edit that state. + shape. The exact file must also have a GitHub OIDC/Sigstore attestation from + the protected watcher workflow, bound to its source commit and run-specific + predicate. Runtime Codex cannot mint that identity, invoke this exception, + or edit that state. - Bind the required check to the GitHub Actions app, preventing another app from satisfying the same context name. - Require conversation resolution. diff --git a/maintenance/control.py b/maintenance/control.py index 1f7f7be..ba8c155 100755 --- a/maintenance/control.py +++ b/maintenance/control.py @@ -31,6 +31,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") ACTION_KEY_RE = re.compile( r"^(no_change:[0-9a-f]{16}|new_patch:\d+\.\d+\.\d+|new_branch:\d+\.\d+|" r"branch_eol:\d+\.\d+:\d{4}-\d{2}-\d{2}|" @@ -284,6 +285,33 @@ def validate_evidence_state_record(record: dict[str, Any]) -> None: require(set(capture_ids) == EVIDENCE_CAPTURE_IDS, "evidence capture set changed") +def validate_evidence_attestation_predicate( + predicate: dict[str, Any], + *, + run_id: str, + source_sha: str, + action_key: str, + manifest_digest: str, +) -> None: + require(isinstance(predicate, dict), "evidence attestation predicate must be an object") + require( + set(predicate) == {"schemaVersion", "runId", "sourceSha", "actionKey", "manifestDigest"}, + "evidence attestation predicate fields changed", + ) + require(predicate.get("schemaVersion") == 1, "invalid evidence attestation predicate version") + require(bool(re.fullmatch(r"[1-9][0-9]*", run_id)), "invalid expected watcher run") + require(bool(COMMIT_SHA_RE.fullmatch(source_sha)), "invalid expected watcher source") + require(bool(ACTION_KEY_RE.fullmatch(action_key)), "invalid expected watcher action") + require(bool(SHA256_RE.fullmatch(manifest_digest)), "invalid expected evidence manifest") + require(predicate.get("runId") == run_id, "evidence attestation run mismatch") + require(predicate.get("sourceSha") == source_sha, "evidence attestation source mismatch") + require(predicate.get("actionKey") == action_key, "evidence attestation action mismatch") + require( + predicate.get("manifestDigest") == manifest_digest, + "evidence attestation manifest mismatch", + ) + + def load_capture(manifest_path: pathlib.Path, capture_id: str) -> tuple[dict[str, Any], bytes]: manifest = load_json(manifest_path) require(isinstance(manifest, dict), "capture manifest must be an object") diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 27cf996..a2d7dbd 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -23,6 +23,7 @@ transition_event, validate_archive, validate_completion_assessment, + validate_evidence_attestation_predicate, validate_evidence_state_record, verify_merge, watch_decision, @@ -133,6 +134,25 @@ def test_deterministic_evidence_state_shape_is_fail_closed(self): with self.assertRaisesRegex(ControlError, "not healthy"): validate_evidence_state_record(record) + def test_evidence_attestation_is_bound_to_the_exact_watcher_run(self): + predicate = { + "schemaVersion": 1, + "runId": "30359936149", + "sourceSha": "a" * 40, + "actionKey": "no_change:" + "c" * 16, + "manifestDigest": "sha256:" + "c" * 64, + } + expected = { + "run_id": predicate["runId"], + "source_sha": predicate["sourceSha"], + "action_key": predicate["actionKey"], + "manifest_digest": predicate["manifestDigest"], + } + validate_evidence_attestation_predicate(predicate, **expected) + predicate["runId"] = "30359936150" + with self.assertRaisesRegex(ControlError, "run mismatch"): + validate_evidence_attestation_predicate(predicate, **expected) + def test_illegal_event_transition_fails_closed(self): with self.assertRaises(ControlError): transition_event({"state": "detected"}, "complete", [{"digest": "x"}]) From bb08d5de74c32b12dfeda57d61cc7cc88ebdbc44 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 16:30:02 +0300 Subject: [PATCH 5/7] fix: await only required evidence checks --- .github/workflows/maintenance-watch.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maintenance-watch.yml b/.github/workflows/maintenance-watch.yml index 108ab0e..eb51ad3 100644 --- a/.github/workflows/maintenance-watch.yml +++ b/.github/workflows/maintenance-watch.yml @@ -244,9 +244,10 @@ jobs: --title "chore: record reviewed maintenance evidence" \ --body "Opaque evidence state for a reviewed no-change result.")" number="${url##*/}" - gh pr checks "$number" --watch --fail-fast --interval 10 - gh pr checks "$number" --json name,bucket,link > maintenance-plan-download/no-change-checks.json + gh pr checks "$number" --required --watch --fail-fast --interval 10 + gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/no-change-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json + jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" git fetch origin main test "$(git rev-parse origin/main)" = "$base" From fe9ee5f5f530b36ef5fc00cfd3dd39a25183d95c Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 16:39:39 +0300 Subject: [PATCH 6/7] fix: make evidence recording quiet and recoverable --- .github/workflows/maintenance-watch.yml | 35 ++++++++++--- .github/workflows/protected-controls.yml | 66 +++++++++++++++--------- maintenance/control.py | 23 ++++++++- scripts/watch-maintenance-evidence | 19 ++++++- tests/test_maintenance.py | 35 +++++++++++++ 5 files changed, 144 insertions(+), 34 deletions(-) diff --git a/.github/workflows/maintenance-watch.yml b/.github/workflows/maintenance-watch.yml index eb51ad3..b08324f 100644 --- a/.github/workflows/maintenance-watch.yml +++ b/.github/workflows/maintenance-watch.yml @@ -37,10 +37,15 @@ jobs: - name: Decide whether an agent call is required id: decision run: | + self_update=() + if [[ "$(git diff-tree --no-commit-id --name-only -r HEAD)" == "maintenance-state/last-evidence.json" ]]; then + self_update=(--self-evidence-update) + fi ./scripts/watch-maintenance-evidence \ --manifest maintenance-run/evidence/evidence-manifest.json \ --previous maintenance-state/last-evidence.json \ --events maintenance-events \ + "${self_update[@]}" \ --output maintenance-run/watch-decision.json echo "trigger=$(jq -r .trigger maintenance-run/watch-decision.json)" >> "$GITHUB_OUTPUT" - name: Record exact preconditions @@ -166,6 +171,7 @@ jobs: && echo "enabled=true" >> "$GITHUB_OUTPUT" \ || echo "enabled=false" >> "$GITHUB_OUTPUT" - name: Prepare deterministic no-change evidence + id: evidence if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' run: | git checkout -B "maintenance/evidence-${{ github.run_id }}" origin/main @@ -176,6 +182,11 @@ jobs: --argjson captureDigests "$(jq '[.captures[] | {captureId,digest,status}]' maintenance-plan-download/evidence/evidence-manifest.json)" \ '{schemaVersion:1,manifestDigest:$manifestDigest,planDigest:$planDigest,captures:$captureDigests}' \ > maintenance-state/last-evidence.json + if [[ -z "$(git status --porcelain -- maintenance-state/last-evidence.json)" ]]; then + echo "already_recorded=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "already_recorded=false" >> "$GITHUB_OUTPUT" jq -n \ --arg runId "${{ github.run_id }}" \ --arg sourceSha "${{ needs.investigate.outputs.base_sha }}" \ @@ -184,7 +195,7 @@ jobs: '{schemaVersion:1,runId:$runId,sourceSha:$sourceSha,actionKey:$actionKey,manifestDigest:$manifestDigest}' \ > maintenance-plan-download/evidence-attestation-predicate.json - name: Attest deterministic no-change evidence - if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' + if: needs.investigate.outputs.action == 'no_change' && steps.operator.outputs.enabled == 'true' && steps.evidence.outputs.already_recorded == 'false' uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 with: subject-path: maintenance-state/last-evidence.json @@ -192,6 +203,7 @@ jobs: predicate-path: maintenance-plan-download/evidence-attestation-predicate.json - name: Dispatch implementation or no-edit release env: + EVIDENCE_ALREADY_RECORDED: ${{ steps.evidence.outputs.already_recorded }} GH_TOKEN: ${{ github.token }} run: | if [[ "$(jq -r .unattendedMutation .github/maintenance-operator.json)" != "enabled" ]]; then @@ -199,6 +211,10 @@ jobs: exit 0 fi action="${{ needs.investigate.outputs.action }}" + if [[ "$action" == "no_change" && "$EVIDENCE_ALREADY_RECORDED" == "true" ]]; then + echo "The exact deterministic evidence state is already recorded." + exit 0 + fi if [[ "$action" == "blocked" || "$action" == "needs_human" ]]; then jq -n \ --arg actionKey "${{ needs.investigate.outputs.action_key }}" \ @@ -232,6 +248,7 @@ jobs: fi if [[ "$action" == "no_change" ]]; then + branch="maintenance/evidence-${{ github.run_id }}" base="$(git rev-parse HEAD)" git add maintenance-state/last-evidence.json git -c user.name=maintenance-watcher -c user.email=maintenance@invalid \ @@ -239,11 +256,17 @@ jobs: head="$(git rev-parse HEAD)" record_digest="sha256:$(shasum -a 256 maintenance-state/last-evidence.json | awk '{print $1}')" gh auth setup-git - git push origin HEAD - url="$(gh pr create --base main --head "maintenance/evidence-${{ github.run_id }}" \ - --title "chore: record reviewed maintenance evidence" \ - --body "Opaque evidence state for a reviewed no-change result.")" - number="${url##*/}" + if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then + git fetch origin "$branch:refs/remotes/origin/$branch" + fi + git push --force-with-lease origin "HEAD:refs/heads/$branch" + number="$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" + if [[ -z "$number" ]]; then + url="$(gh pr create --base main --head "$branch" \ + --title "chore: record reviewed maintenance evidence" \ + --body "Opaque evidence state for a reviewed no-change result.")" + number="${url##*/}" + fi gh pr checks "$number" --required --watch --fail-fast --interval 10 gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/no-change-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index f602edb..8d4b3cb 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -40,6 +40,7 @@ jobs: import subprocess import sys import tempfile + import time from maintenance.control import ( ControlError, @@ -121,39 +122,54 @@ jobs: with tempfile.NamedTemporaryFile() as evidence_file: evidence_file.write(decoded) evidence_file.flush() - verification = subprocess.run( - [ - "gh", - "attestation", - "verify", - evidence_file.name, - "--repo", - repo, - "--signer-workflow", - f"{repo}/.github/workflows/maintenance-watch.yml", - "--source-ref", - "refs/heads/main", - "--source-digest", - base, - "--predicate-type", - "https://bigpixelrocket.dev/maintenance/evidence-state/v1", - "--format", - "json", - ], - check=True, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) + command = [ + "gh", + "attestation", + "verify", + evidence_file.name, + "--repo", + repo, + "--signer-workflow", + f"{repo}/.github/workflows/maintenance-watch.yml", + "--source-ref", + "refs/heads/main", + "--source-digest", + base, + "--predicate-type", + "https://bigpixelrocket.dev/maintenance/evidence-state/v1", + "--deny-self-hosted-runners", + "--format", + "json", + ] + verification = None + for attempt in range(3): + candidate = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if candidate.returncode == 0: + verification = candidate + break + time.sleep(2**attempt) + if verification is None: + raise RuntimeError( + f"attestation was unavailable after bounded retries: {candidate.stderr}" + ) attestations = json.loads(verification.stdout) + if not isinstance(attestations, list): + raise TypeError("attestation verifier returned a non-array result") predicates = [ item["verificationResult"]["statement"]["predicate"] for item in attestations ] except ( KeyError, + RuntimeError, + TypeError, json.JSONDecodeError, - subprocess.CalledProcessError, ) as error: print(f"Evidence attestation verification failed: {error}", file=sys.stderr) raise SystemExit(1) from error diff --git a/maintenance/control.py b/maintenance/control.py index ba8c155..736676a 100755 --- a/maintenance/control.py +++ b/maintenance/control.py @@ -842,6 +842,8 @@ def watch_decision( previous: dict[str, Any], events: Iterable[dict[str, Any]], health: dict[str, Any], + *, + self_evidence_update: bool = False, ) -> dict[str, Any]: incomplete = sorted( event.get("actionKey") @@ -855,7 +857,26 @@ def watch_decision( elif incomplete: trigger = "event_incomplete" elif previous.get("manifestDigest") != manifest.get("manifestDigest"): - trigger = "evidence_changed" + current_captures = { + item.get("captureId"): (item.get("status"), item.get("digest")) + for item in manifest.get("captures", []) + if isinstance(item, dict) + } + previous_captures = { + item.get("captureId"): (item.get("status"), item.get("digest")) + for item in previous.get("captures", []) + if isinstance(item, dict) + } + changed_captures = { + capture_id + for capture_id in set(current_captures) | set(previous_captures) + if current_captures.get(capture_id) != previous_captures.get(capture_id) + } + trigger = ( + "quiet" + if self_evidence_update and changed_captures == {"php_bin_state"} + else "evidence_changed" + ) else: trigger = "quiet" return { diff --git a/scripts/watch-maintenance-evidence b/scripts/watch-maintenance-evidence index 226d1bd..8872f10 100755 --- a/scripts/watch-maintenance-evidence +++ b/scripts/watch-maintenance-evidence @@ -7,7 +7,13 @@ import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) -from maintenance.control import ControlError, load_json, watch_decision, write_json # noqa: E402 +from maintenance.control import ( # noqa: E402 + ControlError, + load_json, + validate_evidence_state_record, + watch_decision, + write_json, +) parser = argparse.ArgumentParser() @@ -15,12 +21,15 @@ parser.add_argument("--manifest", type=pathlib.Path, required=True) parser.add_argument("--previous", type=pathlib.Path, required=True) parser.add_argument("--events", type=pathlib.Path, required=True) parser.add_argument("--health", type=pathlib.Path) +parser.add_argument("--self-evidence-update", action="store_true") parser.add_argument("--output", type=pathlib.Path, required=True) args = parser.parse_args() try: manifest = load_json(args.manifest) previous = load_json(args.previous) if args.previous.exists() else {} + if previous: + validate_evidence_state_record(previous) if args.health: if not args.health.is_file(): raise ControlError(f"supplied health input is missing: {args.health}") @@ -42,7 +51,13 @@ try: raise ControlError("previous evidence state is missing and event reconstruction is ambiguous") if matching: previous["manifestDigest"] = matching[0] - decision = watch_decision(manifest, previous, events, health) + decision = watch_decision( + manifest, + previous, + events, + health, + self_evidence_update=args.self_evidence_update, + ) write_json(args.output, decision) print(json.dumps(decision)) except (ControlError, OSError) as error: diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index a2d7dbd..28a65f1 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -66,6 +66,41 @@ def test_quiet_snapshot_does_not_wake_agent(self): self.assertEqual("quiet", decision["trigger"]) self.assertFalse(decision["modelCall"]) + def test_evidence_recording_commit_does_not_wake_itself(self): + previous = { + "manifestDigest": "sha256:" + "a" * 64, + "captures": [ + {"captureId": "php_bin_state", "status": 200, "digest": "sha256:" + "b" * 64}, + {"captureId": "php_release_feed", "status": 200, "digest": "sha256:" + "c" * 64}, + ], + } + current = { + "manifestDigest": "sha256:" + "d" * 64, + "captures": [ + {"captureId": "php_bin_state", "status": 200, "digest": "sha256:" + "e" * 64}, + {"captureId": "php_release_feed", "status": 200, "digest": "sha256:" + "c" * 64}, + ], + } + ordinary = watch_decision(current, previous, [], {"healthy": True}) + self_update = watch_decision( + current, + previous, + [], + {"healthy": True}, + self_evidence_update=True, + ) + self.assertEqual("evidence_changed", ordinary["trigger"]) + self.assertEqual("quiet", self_update["trigger"]) + current["captures"][1]["digest"] = "sha256:" + "f" * 64 + external_change = watch_decision( + current, + previous, + [], + {"healthy": True}, + self_evidence_update=True, + ) + self.assertEqual("evidence_changed", external_change["trigger"]) + def test_completion_go_is_mechanical(self): contract = { "contractVersion": 1, From 5cae3543e1f11450ddcf4c2ade2d6351efd19d27 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 28 Jul 2026 16:43:51 +0300 Subject: [PATCH 7/7] fix: align production workflow bounds --- .github/workflows/maintenance-implementation.yml | 2 +- .github/workflows/maintenance-watch.yml | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/maintenance-implementation.yml b/.github/workflows/maintenance-implementation.yml index cc1bc77..a85d6a9 100644 --- a/.github/workflows/maintenance-implementation.yml +++ b/.github/workflows/maintenance-implementation.yml @@ -303,7 +303,7 @@ jobs: needs: [validate, validate-repair] if: always() && (needs.validate.outputs.passed == 'true' || needs['validate-repair'].result == 'success') runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 130 permissions: contents: write pull-requests: write diff --git a/.github/workflows/maintenance-watch.yml b/.github/workflows/maintenance-watch.yml index b08324f..3acb160 100644 --- a/.github/workflows/maintenance-watch.yml +++ b/.github/workflows/maintenance-watch.yml @@ -348,9 +348,10 @@ jobs: url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \ --body "Deterministic EOL completion bound to exact cross-repository readiness.")" number="${url##*/}" - gh pr checks "$number" --watch --fail-fast --interval 10 - gh pr checks "$number" --json name,bucket,link > maintenance-plan-download/eol-checks.json + gh pr checks "$number" --required --watch --fail-fast --interval 10 + gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/eol-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json + jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" git fetch origin main test "$(git rev-parse origin/main)" = "$base" @@ -371,8 +372,8 @@ jobs: notify-failure: name: Notify actionable watcher failure - needs: investigate - if: always() && needs.investigate.result == 'failure' + needs: [investigate, coordinate] + if: always() && (needs.investigate.result == 'failure' || needs.coordinate.result == 'failure') runs-on: ubuntu-latest timeout-minutes: 5 permissions: