From 7e83d2da0892dd529f237873ebc9aa4e8c553093 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Wed, 5 Aug 2026 19:08:56 -0500 Subject: [PATCH 1/3] fix: restore agent rail startup recovery --- .github/workflows/agents-auto-label.yml | 3 +- config/template-drift-allowlist.txt | 11 +++- docs/INTEGRATION_GUIDE.md | 6 ++ docs/ops/CONSUMER_REPO_MAINTENANCE.md | 15 +++++ .../workflow_startup_failure_diagnostic.py | 23 +++++++- .../.github/workflows/agents-auto-label.yml | 3 +- ...est_workflow_startup_failure_diagnostic.py | 57 ++++++++++++++++++- .../test_auto_label_checkout_isolation.py | 43 ++++++++++++++ 8 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 tests/workflows/test_auto_label_checkout_isolation.py diff --git a/.github/workflows/agents-auto-label.yml b/.github/workflows/agents-auto-label.yml index 5abd07d68..8887050db 100644 --- a/.github/workflows/agents-auto-label.yml +++ b/.github/workflows/agents-auto-label.yml @@ -50,11 +50,12 @@ jobs: github.event.workflow_run.repository.default_branch }} sparse-checkout: .github/actions/agent-event-eligibility sparse-checkout-cone-mode: false + path: eligibility-source # Escape hatch: set mode: warning if false-negative skips appear post-merge. - name: Check event eligibility id: eligibility - uses: ./.github/actions/agent-event-eligibility + uses: ./eligibility-source/.github/actions/agent-event-eligibility with: expected-actions: opened,reopened,edited custom-predicate: >- diff --git a/config/template-drift-allowlist.txt b/config/template-drift-allowlist.txt index b4a4752b3..75acb84bc 100644 --- a/config/template-drift-allowlist.txt +++ b/config/template-drift-allowlist.txt @@ -10,6 +10,11 @@ # divergence no longer change these fingerprints, so this allowlist stops going # stale on dependency bumps; only a genuine logic change to a root workflow will. # +# 2026-08-05 re-baseline: auto-label now isolates the preliminary eligibility +# sparse checkout under eligibility-source/ in both root and consumer workflows. +# The remaining root/template differences are the already-reviewed consumer +# action-pin and auth-plumbing contract. +# # 2026-07-24 re-baseline: refreshed fingerprints for 13 entries (belt 71/72/73, # auto-label, autofix-dispatcher, capability-check, decompose, dedup, guard, # issue-optimizer, keepalive-loop-reporter, verifier, weekly-metrics) that went @@ -66,9 +71,9 @@ reason = Existing reviewed baseline drift re-baselined 2026-06-19: runtime AC me [pair.5] main = .github/workflows/agents-auto-label.yml template = templates/consumer-repo/.github/workflows/agents-auto-label.yml -main_sha256 = 6276b24994010fab62c79085cc41d67ca2d0031580df2f5892c5ed2c7d1e7eba -template_sha256 = c351fecad5ee3bc4231fc33d7e0c3edbcf539f57105d649601bfbf435561b1f3 -reason = Intentional divergence updated 2026-07-31: root and consumer queries now reject non-positive or invalid bounds and share executable guard tests while retaining consumer SHA pins and auth plumbing. Do not align wholesale: that would strip the consumer security contract. +main_sha256 = 2b69ddf783e49273b966ab87f0caaf65c0a3c2b803bf46eba240d0637eeda00b +template_sha256 = 60a5f4bff00adcf4ab9fe967f6f0d4b53d8be8d1111c545aa0778c7424ff69b9 +reason = Intentional divergence updated 2026-08-05: root and consumer workflows both isolate the eligibility sparse checkout under eligibility-source/ while retaining consumer SHA pins and auth plumbing. Do not align wholesale: that would strip the consumer security contract. [pair.6] main = .github/workflows/agents-autofix-dispatcher.yml diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 1b487ee18..2585cd223 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -708,6 +708,12 @@ python scripts/workflow_startup_failure_diagnostic.py --repo OWNER/REPO --run-id This inspects check-runs for the same head SHA/run ID and prints the parser error title/summary text that is not visible in `actions/runs//jobs`. +The same diagnostic also recognizes `action_required` runs with zero jobs. +GitHub may hold a public-repository workflow before job creation under its +unproven-workflow protection. Review the workflow file and use **Approve and +run** from an authenticated GitHub web session; the REST approval endpoint for +fork pull-request runs does not approve this class of hold. + ### Startup Failure (Caller Workflow Permissions) diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 1368019d8..550ef9fc3 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -138,6 +138,21 @@ For bugs affecting multiple repos, create a tracking issue with: **Example**: `workflow_run` trigger without handler job **Detection**: Search for trigger in `on:` block, verify matching job exists +### Sparse checkout state leaking into a later checkout + +**Pattern**: A job checks out one local action with `sparse-checkout`, then runs a +second `actions/checkout` into the same workspace and expects the complete +repository to be present. + +**Problem**: The later checkout can retain the first checkout's sparse worktree +configuration. A subsequent local action then fails before useful work begins +with `Can't find 'action.yml'`, even though the action is present on the +repository's default branch. + +**Fix**: Put the preliminary sparse checkout in a dedicated `path:` and invoke +the local action from that path. Reserve the workspace root for the later full +checkout. The auto-label workflows use `eligibility-source/` for this reason. + ### Hardcoded Values **Pattern**: Repository-specific values in templates diff --git a/scripts/workflow_startup_failure_diagnostic.py b/scripts/workflow_startup_failure_diagnostic.py index f8b71809e..993caaa51 100644 --- a/scripts/workflow_startup_failure_diagnostic.py +++ b/scripts/workflow_startup_failure_diagnostic.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Capture startup_failure details for a workflow run via GitHub check-runs.""" +"""Diagnose zero-job workflow startup failures and approval holds.""" from __future__ import annotations @@ -98,6 +98,18 @@ def diagnose_startup_failure(repo: str, run_id: int) -> dict[str, Any]: jobs = jobs_payload.get("jobs", []) jobs_count = len(jobs) if isinstance(jobs, list) else 0 + approval_hold = None + if run_payload.get("conclusion") == "action_required" and jobs_count == 0: + approval_hold = { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "github_unproven_workflow_protection", + "approval_url": f"https://github.com/{repo}/actions/runs/{run_id}", + "remediation": ( + "Review the workflow file, then use Approve and run from an " + "authenticated GitHub web session. The workflow-run approval " + "REST endpoint does not cover this protection." + ), + } return { "repo": repo, "run_id": run_id, @@ -106,6 +118,7 @@ def diagnose_startup_failure(repo: str, run_id: int) -> dict[str, Any]: "run_status": run_payload.get("status", ""), "head_sha": head_sha, "jobs_count": jobs_count, + "approval_hold": approval_hold, "startup_failures": findings, } @@ -148,11 +161,17 @@ def main(argv: list[str] | None = None) -> int: return 1 print(json.dumps(report, indent=2)) + if report["approval_hold"]: + return 0 if report["jobs_count"] == 0 and report["startup_failures"]: return 0 if report["startup_failures"]: return 0 - print("No matching startup_failure check-runs found for this run.", file=sys.stderr) + print( + "No matching startup_failure check-runs or zero-job approval hold found " + "for this run.", + file=sys.stderr, + ) return 2 diff --git a/templates/consumer-repo/.github/workflows/agents-auto-label.yml b/templates/consumer-repo/.github/workflows/agents-auto-label.yml index d2f45207c..b671c8c4e 100644 --- a/templates/consumer-repo/.github/workflows/agents-auto-label.yml +++ b/templates/consumer-repo/.github/workflows/agents-auto-label.yml @@ -50,11 +50,12 @@ jobs: github.event.workflow_run.repository.default_branch }} sparse-checkout: .github/actions/agent-event-eligibility sparse-checkout-cone-mode: false + path: eligibility-source # Escape hatch: set mode: warning if false-negative skips appear post-merge. - name: Check event eligibility id: eligibility - uses: ./.github/actions/agent-event-eligibility + uses: ./eligibility-source/.github/actions/agent-event-eligibility with: expected-actions: opened,reopened,edited custom-predicate: >- diff --git a/tests/scripts/test_workflow_startup_failure_diagnostic.py b/tests/scripts/test_workflow_startup_failure_diagnostic.py index 03d81788e..6161b8bce 100644 --- a/tests/scripts/test_workflow_startup_failure_diagnostic.py +++ b/tests/scripts/test_workflow_startup_failure_diagnostic.py @@ -114,6 +114,7 @@ def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: report = diag.diagnose_startup_failure("owner/repo", 555) assert report["jobs_count"] == 0 + assert report["approval_hold"] is None assert report["head_sha"] == "abc123" assert len(report["startup_failures"]) == 1 finding = report["startup_failures"][0] @@ -199,7 +200,61 @@ def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: exit_code = diag.main(["--repo", "owner/repo", "--run-id", "111"]) assert exit_code == 2 - assert "No matching startup_failure check-runs found" in capsys.readouterr().err + assert "No matching startup_failure check-runs or zero-job approval hold" in ( + capsys.readouterr().err + ) + + +def test_diagnose_zero_job_action_required_as_web_approval_hold(monkeypatch) -> None: + responses = [ + { + "head_sha": "abc123", + "name": "Auto-Label Issues", + "conclusion": "action_required", + "status": "completed", + }, + {"jobs": []}, + {"check_runs": []}, + ] + + def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: + return responses.pop(0) + + monkeypatch.setattr(diag, "_gh_api", fake_gh_api) + report = diag.diagnose_startup_failure("owner/repo", 555) + + assert report["approval_hold"] == { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "github_unproven_workflow_protection", + "approval_url": "https://github.com/owner/repo/actions/runs/555", + "remediation": ( + "Review the workflow file, then use Approve and run from an " + "authenticated GitHub web session. The workflow-run approval " + "REST endpoint does not cover this protection." + ), + } + + +def test_main_accepts_zero_job_action_required_hold(monkeypatch, capsys) -> None: + responses = [ + { + "head_sha": "abc123", + "name": "Auto-Label Issues", + "conclusion": "action_required", + "status": "completed", + }, + {"jobs": []}, + {"check_runs": []}, + ] + + def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: + return responses.pop(0) + + monkeypatch.setattr(diag, "_gh_api", fake_gh_api) + exit_code = diag.main(["--repo", "owner/repo", "--run-id", "555"]) + + assert exit_code == 0 + assert "pre_job_workflow_approval" in capsys.readouterr().out def test_main_returns_1_when_diagnosis_raises(monkeypatch, capsys) -> None: diff --git a/tests/workflows/test_auto_label_checkout_isolation.py b/tests/workflows/test_auto_label_checkout_isolation.py new file mode 100644 index 000000000..0bc017456 --- /dev/null +++ b/tests/workflows/test_auto_label_checkout_isolation.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import yaml + +WORKFLOWS = ( + Path(".github/workflows/agents-auto-label.yml"), + Path("templates/consumer-repo/.github/workflows/agents-auto-label.yml"), +) + + +def _steps(path: Path) -> list[dict[str, object]]: + workflow = yaml.safe_load(path.read_text(encoding="utf-8")) + return workflow["jobs"]["auto-label"]["steps"] + + +def test_auto_label_isolates_eligibility_sparse_checkout() -> None: + for path in WORKFLOWS: + steps = _steps(path) + eligibility_checkout = next( + step for step in steps if step.get("name") == "Checkout eligibility action" + ) + eligibility = next( + step for step in steps if step.get("name") == "Check event eligibility" + ) + + assert eligibility_checkout["with"]["path"] == "eligibility-source", path + assert ( + eligibility["uses"] + == "./eligibility-source/.github/actions/agent-event-eligibility" + ), path + + +def test_auto_label_full_checkout_keeps_workspace_root() -> None: + for path in WORKFLOWS: + steps = _steps(path) + full_checkout = next( + step for step in steps if step.get("name") == "Checkout repository" + ) + + assert "path" not in full_checkout.get("with", {}), path + assert any( + step.get("uses") == "./.github/actions/setup-api-client" for step in steps + ), path From 67d29659cf9eab96bed467e4d0b3b769dd5df24b Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Wed, 5 Aug 2026 20:40:31 -0500 Subject: [PATCH 2/3] style: black-format diagnostic and auto-label isolation test Co-authored-by: Cursor --- scripts/workflow_startup_failure_diagnostic.py | 3 +-- .../test_auto_label_checkout_isolation.py | 15 ++++----------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/scripts/workflow_startup_failure_diagnostic.py b/scripts/workflow_startup_failure_diagnostic.py index 993caaa51..5683fe3fd 100644 --- a/scripts/workflow_startup_failure_diagnostic.py +++ b/scripts/workflow_startup_failure_diagnostic.py @@ -168,8 +168,7 @@ def main(argv: list[str] | None = None) -> int: if report["startup_failures"]: return 0 print( - "No matching startup_failure check-runs or zero-job approval hold found " - "for this run.", + "No matching startup_failure check-runs or zero-job approval hold found " "for this run.", file=sys.stderr, ) return 2 diff --git a/tests/workflows/test_auto_label_checkout_isolation.py b/tests/workflows/test_auto_label_checkout_isolation.py index 0bc017456..c37361dfd 100644 --- a/tests/workflows/test_auto_label_checkout_isolation.py +++ b/tests/workflows/test_auto_label_checkout_isolation.py @@ -19,25 +19,18 @@ def test_auto_label_isolates_eligibility_sparse_checkout() -> None: eligibility_checkout = next( step for step in steps if step.get("name") == "Checkout eligibility action" ) - eligibility = next( - step for step in steps if step.get("name") == "Check event eligibility" - ) + eligibility = next(step for step in steps if step.get("name") == "Check event eligibility") assert eligibility_checkout["with"]["path"] == "eligibility-source", path assert ( - eligibility["uses"] - == "./eligibility-source/.github/actions/agent-event-eligibility" + eligibility["uses"] == "./eligibility-source/.github/actions/agent-event-eligibility" ), path def test_auto_label_full_checkout_keeps_workspace_root() -> None: for path in WORKFLOWS: steps = _steps(path) - full_checkout = next( - step for step in steps if step.get("name") == "Checkout repository" - ) + full_checkout = next(step for step in steps if step.get("name") == "Checkout repository") assert "path" not in full_checkout.get("with", {}), path - assert any( - step.get("uses") == "./.github/actions/setup-api-client" for step in steps - ), path + assert any(step.get("uses") == "./.github/actions/setup-api-client" for step in steps), path From 7c8f95b0d65d56c8903ed461dd59d565ba95b4ce Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Wed, 5 Aug 2026 20:49:48 -0500 Subject: [PATCH 3/3] fix: distinguish fork PR approval holds in startup diagnostic Co-authored-by: Cursor --- docs/INTEGRATION_GUIDE.md | 12 ++-- .../workflow_startup_failure_diagnostic.py | 69 ++++++++++++++++--- ...est_workflow_startup_failure_diagnostic.py | 59 ++++++++++++++++ 3 files changed, 126 insertions(+), 14 deletions(-) diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 2585cd223..9b0bf51c1 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -709,10 +709,14 @@ This inspects check-runs for the same head SHA/run ID and prints the parser erro title/summary text that is not visible in `actions/runs//jobs`. The same diagnostic also recognizes `action_required` runs with zero jobs. -GitHub may hold a public-repository workflow before job creation under its -unproven-workflow protection. Review the workflow file and use **Approve and -run** from an authenticated GitHub web session; the REST approval endpoint for -fork pull-request runs does not approve this class of hold. +When the run event is a public-fork `pull_request` and `head_repository.fork` +is true, classify it as a fork contributor approval hold (REST +`/actions/runs/{id}/approve` can recover it). Otherwise treat it as GitHub's +unproven-workflow protection: review the workflow file and use **Approve and +run** from an authenticated GitHub web session; the fork-PR REST approval +endpoint does not cover that class of hold. If the event is `pull_request` but +fork status cannot be determined, report an unspecified approval hold and +inspect `event` + `head_repository` before choosing remediation. ### Startup Failure (Caller Workflow Permissions) diff --git a/scripts/workflow_startup_failure_diagnostic.py b/scripts/workflow_startup_failure_diagnostic.py index 5683fe3fd..b3e4aa51d 100644 --- a/scripts/workflow_startup_failure_diagnostic.py +++ b/scripts/workflow_startup_failure_diagnostic.py @@ -100,16 +100,7 @@ def diagnose_startup_failure(repo: str, run_id: int) -> dict[str, Any]: jobs_count = len(jobs) if isinstance(jobs, list) else 0 approval_hold = None if run_payload.get("conclusion") == "action_required" and jobs_count == 0: - approval_hold = { - "failure_phase": "pre_job_workflow_approval", - "suspected_root_cause": "github_unproven_workflow_protection", - "approval_url": f"https://github.com/{repo}/actions/runs/{run_id}", - "remediation": ( - "Review the workflow file, then use Approve and run from an " - "authenticated GitHub web session. The workflow-run approval " - "REST endpoint does not cover this protection." - ), - } + approval_hold = _classify_zero_job_approval_hold(repo, run_id, run_payload) return { "repo": repo, "run_id": run_id, @@ -123,6 +114,64 @@ def diagnose_startup_failure(repo: str, run_id: int) -> dict[str, Any]: } +def _head_repository_is_fork(repo: str, run_payload: dict[str, Any]) -> bool | None: + """Return True/False when fork status is known; None when it cannot be told.""" + head_repo = run_payload.get("head_repository") + if not isinstance(head_repo, dict): + return None + if "fork" in head_repo: + return bool(head_repo.get("fork")) + head_full = str(head_repo.get("full_name") or "").strip() + if not head_full: + return None + return head_full.lower() != repo.lower() + + +def _classify_zero_job_approval_hold( + repo: str, run_id: int, run_payload: dict[str, Any] +) -> dict[str, str]: + """Distinguish fork-PR REST-approvable holds from unproven-workflow web holds.""" + approval_url = f"https://github.com/{repo}/actions/runs/{run_id}" + event = str(run_payload.get("event") or "").strip() + is_fork = _head_repository_is_fork(repo, run_payload) + + if event == "pull_request" and is_fork is True: + return { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "fork_contributor_approval_hold", + "approval_url": approval_url, + "remediation": ( + "Public-fork pull-request runs awaiting contributor approval can be " + "recovered with POST /repos/{owner}/{repo}/actions/runs/{run_id}/approve " + "(or Approve and run in the GitHub UI)." + ), + } + + if event == "pull_request" and is_fork is None: + return { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "workflow_approval_hold_unspecified", + "approval_url": approval_url, + "remediation": ( + "Zero-job action_required on a pull_request run: inspect event and " + "head_repository.fork before choosing remediation. Fork contributor " + "holds accept the workflow-run approval REST endpoint; unproven-workflow " + "holds require Approve and run in an authenticated GitHub web session." + ), + } + + return { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "github_unproven_workflow_protection", + "approval_url": approval_url, + "remediation": ( + "Review the workflow file, then use Approve and run from an " + "authenticated GitHub web session. The workflow-run approval " + "REST endpoint does not cover this protection." + ), + } + + def _classify_startup_failure(summary: str, title: str, text: str) -> tuple[str, str]: """Best-effort classification for parse-time startup failures.""" blob = "\n".join((title, summary, text)).lower() diff --git a/tests/scripts/test_workflow_startup_failure_diagnostic.py b/tests/scripts/test_workflow_startup_failure_diagnostic.py index 6161b8bce..5a327a793 100644 --- a/tests/scripts/test_workflow_startup_failure_diagnostic.py +++ b/tests/scripts/test_workflow_startup_failure_diagnostic.py @@ -212,6 +212,7 @@ def test_diagnose_zero_job_action_required_as_web_approval_hold(monkeypatch) -> "name": "Auto-Label Issues", "conclusion": "action_required", "status": "completed", + "event": "issues", }, {"jobs": []}, {"check_runs": []}, @@ -235,6 +236,63 @@ def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: } +def test_diagnose_zero_job_fork_pr_as_rest_approvable_hold(monkeypatch) -> None: + responses = [ + { + "head_sha": "abc123", + "name": "CI", + "conclusion": "action_required", + "status": "completed", + "event": "pull_request", + "head_repository": {"full_name": "contributor/repo", "fork": True}, + }, + {"jobs": []}, + {"check_runs": []}, + ] + + def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: + return responses.pop(0) + + monkeypatch.setattr(diag, "_gh_api", fake_gh_api) + report = diag.diagnose_startup_failure("owner/repo", 555) + + assert report["approval_hold"] == { + "failure_phase": "pre_job_workflow_approval", + "suspected_root_cause": "fork_contributor_approval_hold", + "approval_url": "https://github.com/owner/repo/actions/runs/555", + "remediation": ( + "Public-fork pull-request runs awaiting contributor approval can be " + "recovered with POST /repos/{owner}/{repo}/actions/runs/{run_id}/approve " + "(or Approve and run in the GitHub UI)." + ), + } + + +def test_diagnose_zero_job_pr_without_head_repo_is_unspecified(monkeypatch) -> None: + responses = [ + { + "head_sha": "abc123", + "name": "CI", + "conclusion": "action_required", + "status": "completed", + "event": "pull_request", + }, + {"jobs": []}, + {"check_runs": []}, + ] + + def fake_gh_api(path: str, token: str | None = None) -> dict[str, Any]: + return responses.pop(0) + + monkeypatch.setattr(diag, "_gh_api", fake_gh_api) + report = diag.diagnose_startup_failure("owner/repo", 555) + + hold = report["approval_hold"] + assert hold is not None + assert hold["suspected_root_cause"] == "workflow_approval_hold_unspecified" + assert "head_repository.fork" in hold["remediation"] + + def test_main_accepts_zero_job_action_required_hold(monkeypatch, capsys) -> None: responses = [ { @@ -242,6 +300,7 @@ def test_main_accepts_zero_job_action_required_hold(monkeypatch, capsys) -> None "name": "Auto-Label Issues", "conclusion": "action_required", "status": "completed", + "event": "issues", }, {"jobs": []}, {"check_runs": []},