diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..74cf0f7dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,15 @@ # Changelog +## Proposed + +- Route scheduler Actions inventory and force-cancellation through the credential + scoped to the repository hosting each run. Central required-workflow runs use + the receiving repository runner token; target runs retain the explicit + cross-repository Actions token. This prevents an exhausted mutation App quota + from blocking current-head review admission while preserving fail-closed + cross-repository authority. + - **Consolidate current-head queue coalescing into the merge scheduler.** The standalone `Current Head Run Coalescer` duplicated one runner admission for every central pull-request event. Its exact-head worker now runs inside the already-required merge-scheduler job after immutable trusted-source materialization, preserving fail-closed PR/head/base revalidation while deleting the redundant workflow job. All notable changes to the organization automation repository are documented in diff --git a/docs/doctoring/host-scoped-actions-inventory-credentials.md b/docs/doctoring/host-scoped-actions-inventory-credentials.md new file mode 100644 index 0000000000..1fce216207 --- /dev/null +++ b/docs/doctoring/host-scoped-actions-inventory-credentials.md @@ -0,0 +1,44 @@ +# Host-scoped Actions inventory credentials + +Decision date: **2026-09-07** + +## Problem + +The central scheduler reads and cancels workflow runs in two authority domains. +Runs hosted by `ContextualWisdomLab/.github` are visible to the receiving +workflow's runner token. Runs hosted by a target repository require the explicit +cross-repository Actions credential. Sending both through the mutation App +couples current-head admission to that installation's independent rate-limit +bucket and reproduces the queue blocker recorded in +[ContextualWisdomLab/.github#1231](https://github.com/ContextualWisdomLab/.github/pull/1231). + +## Decision + +Select the credential from the repository that hosts the run. Repository +identity is compared case-insensitively. Central inventory and cancellation use +the configured dispatch/runner token; all target repositories continue through +the explicit Actions token. Missing credentials continue to fail at the GitHub +API boundary—there is no paid, anonymous, or mutable-head fallback. + +## Failure scenes + +- If the mutation App quota is exhausted, central current-head discovery still + uses the runner token and can release stale central runs. +- If a target repository is queried, the scheduler never substitutes the + central runner token, whose scope is insufficient. +- If repository casing differs, the same central repository is not + misclassified as a target. + +## Evidence and follow-up + +The permanent regression first appears at RED commit +`8cc62ce8837e456dfac4f592bcbd0786a77e4b81`. The implementation must receive +fresh exact-head GitHub Checks before the PR can leave Proposed status. + +## References + +GitHub. (2026). *REST API endpoints for workflow runs*. +https://docs.github.com/en/rest/actions/workflow-runs + +GitHub. (2026). *Automatic token authentication*. +https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..b5d5bb7754 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3353,3 +3353,20 @@ queries the check-runs API at its own time, order-independently. The implementin their change was safe because they had scoped it narrowly, not because they had checked for the name collision — which is the more useful lesson: **a job name is unique only within one workflow file, and the same name in another file can carry the opposite safety property.** + + +### Central Actions inventory credential routing + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** Central required-workflow inventory and cancellation inherited the + cross-repository Actions credential, so an exhausted App rate-limit bucket + could prevent discovery or cleanup of the current-head review run. +- **Action:** Route each Actions read/cancel operation by the repository hosting + the run. Use the central runner token only for + `ContextualWisdomLab/.github`; preserve the explicit target Actions token for + every other repository. +- **Evidence:** Historical owner PR + [#1231](https://github.com/ContextualWisdomLab/.github/pull/1231); RED commit + `8cc62ce8837e456dfac4f592bcbd0786a77e4b81`; fresh exact-head hosted checks + remain required before integration. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 4df4dac3de..9971920236 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -826,6 +826,21 @@ def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str return run_with_env(args, stdin=stdin, env=env) +def run_github_actions_for_repository( + repo: str, + args: Sequence[str], + *, + stdin: str | None = None, +) -> str: + """Run an Actions command with the credential scoped to its host repository.""" + central_repo = ( + os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "" + ).strip() + if central_repo and repo.casefold() == central_repo.casefold(): + return run_github_dispatch(args, stdin=stdin) + return run_github_actions(args, stdin=stdin) + + def split_repo(repo: str) -> tuple[str, str]: """Split an owner/name repository string into owner and repository name.""" try: @@ -3162,7 +3177,7 @@ def active_workflow_runs( args += ["-f", f"created={created}"] if head_sha: args += ["-f", f"head_sha={head_sha}"] - payload = json.loads(run_github_actions(args)) + payload = json.loads(run_github_actions_for_repository(repo, args)) pages = payload if isinstance(payload, list) else [payload] for page in pages: runs.extend(page.get("workflow_runs") or []) @@ -3411,14 +3426,15 @@ def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, s def cancel_one(run_id: str) -> tuple[str, str | None]: """Return one run id and its bounded GitHub cancellation error, if any.""" try: - run_github_actions( + run_github_actions_for_repository( + repo, [ "gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel", - ] + ], ) except RuntimeError as exc: return run_id, str(exc).replace("\n", "; ")[:600] diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index ba47b89c8d..ac8d40a758 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1785,7 +1785,11 @@ def map(self, func, items): ), ) cancelled = [] - monkeypatch.setattr(sched, "run_github_actions", cancelled.append) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda args, stdin=None: cancelled.append(args), + ) monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda x: None) run_ids = sched.cancel_stale_opencode_runs("owner/repo", "workflow", make_pr(), dry_run=False) @@ -1796,7 +1800,7 @@ def map(self, func, items): def test_force_cancel_failure_logs_reason_and_does_not_raise(monkeypatch, capsys): - def fail_cancel(args): + def fail_cancel(args, stdin=None): raise RuntimeError( "Command failed (1): gh api -X POST " "repos/owner/repo/actions/runs/29263154177/force-cancel; " @@ -1821,7 +1825,7 @@ def fail_cancel(args): def test_force_cancel_multiple_runs_reports_only_failures(monkeypatch): - def maybe_fail(args): + def maybe_fail(args, stdin=None): if "runs/2/force-cancel" in " ".join(args): raise RuntimeError("GitHub returned HTTP 500") return "" @@ -10790,3 +10794,33 @@ def behind_with(nodes): assert "checks are still queued or running" not in resumed.reason assert sched.has_in_flight_check_runs(behind_with([])) is False + + +def test_central_actions_inventory_uses_host_scoped_credentials(monkeypatch): + """Central run reads and cancellation cannot spend the cross-repository App quota.""" + calls = [] + + def fake_run_with_env(args, *, stdin=None, env=None): + calls.append((tuple(args), None if env is None else env.get("GH_TOKEN"))) + return '{"workflow_runs": []}' + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GH_TOKEN", "mutation-app-token") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "target-actions-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "central-runner-token") + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "contextualwisdomlab/.GITHUB", + ) + + sched.active_workflow_runs("ContextualWisdomLab/.github", statuses=("queued",)) + sched.force_cancel_workflow_runs("ContextualWisdomLab/.github", ["101"]) + sched.active_workflow_runs("owner/repo", statuses=("queued",)) + sched.force_cancel_workflow_runs("owner/repo", ["202"]) + + assert [token for _, token in calls] == [ + "central-runner-token", + "central-runner-token", + "target-actions-token", + "target-actions-token", + ]