Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ jobs:
SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}
SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }}
SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
SCHEDULER_WORKFLOW_TOKEN: ${{ github.token }}
SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github
SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }}
run: |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@

## Proposed

- Prove that the scheduler's selected head-mutation credential is present and
distinct from the workflow `github.token`, even when its declared source is
allowlisted. Missing comparison evidence and same-token fallback now fail
closed, and later operator guidance renders from the immutable recorded
decision rather than re-reading mutable environment state.

- 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
Expand Down
45 changes: 45 additions & 0 deletions docs/doctoring/workflow-starting-mutation-credential-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Workflow-starting mutation credential proof

Decision date: **2026-09-07**

## Problem

GitHub does not create a new workflow run for events generated by a workflow's
own `GITHUB_TOKEN`. A declared App or PAT source is therefore insufficient
authority: a missing secret can fall back to `github.token` while retaining an
allowlisted source label. Moving a PR head in that state creates the exact
chicken-and-egg condition the scheduler is intended to prevent—the new head
requires checks that its mutation credential cannot start.

## Decision

At the final mutation boundary, require all of the following:

1. the declared source is workflow-starting;
2. the selected `GH_TOKEN` is present;
3. the workflow-token comparison value is present; and
4. the two token values differ.

Any missing or identical evidence fails closed. The workflow supplies
`SCHEDULER_WORKFLOW_TOKEN` only to the scheduler mutation job. A recorded
withheld decision carries its own reason so later environment changes cannot
rewrite the operator explanation.

## Failure scenes

- A configured secret is empty and expression fallback selects
`github.token`: the mutation is withheld.
- Token comparison evidence is absent: the mutation is withheld.
- A decision is rendered after credentials rotate: the original reason remains
visible.

## Evidence and follow-up

The permanent RED regression is commit
`ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`. Fresh exact-head hosted checks and
independent review remain required.

## Reference

GitHub. (2026). *Automatic token authentication*.
https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication
16 changes: 16 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -3370,3 +3370,19 @@ same name in another file can carry the opposite safety property.**
[#1231](https://github.com/ContextualWisdomLab/.github/pull/1231); RED commit
`8cc62ce8837e456dfac4f592bcbd0786a77e4b81`; fresh exact-head hosted checks
remain required before integration.


### Workflow-starting mutation credential proof

- **Status:** Proposed
- **Owner:** `ContextualWisdomLab/.github`
- **Problem:** An allowlisted credential-source label could authorize a PR head
mutation even when the selected `GH_TOKEN` was missing or had fallen back to
the workflow `github.token`, which cannot trigger the required new
current-head workflow runs.
- **Action:** Require present, distinct selected-token and workflow-token
evidence at every head-mutation boundary; preserve the original rejection
reason for later operator guidance.
- **Evidence:** RED commit
`ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`; fresh exact-head hosted checks
remain required before integration.
95 changes: 59 additions & 36 deletions scripts/ci/pr_review_merge_scheduler_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,34 +455,48 @@ def mutation_token_label() -> str:
return labels.get(source, "workflow GH_TOKEN")


def head_mutation_credential_starts_workflows() -> bool:
"""Return whether scheduler head mutations can start required workflow runs.
def head_mutation_credential_problem() -> str | None:
"""Explain why the selected mutation credential cannot start workflow runs.

GitHub never creates a new workflow run for an event produced with the
workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never
collect the current-head required checks that protected branches demand
(GitHub, 2025).

References:
GitHub. (2025). *Automatic token authentication*.
https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication
workflow GITHUB_TOKEN, so a head moved with that credential cannot
collect protected-branch current-head checks.
"""
return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES


def non_triggering_head_mutation_reason(action: str) -> str:
"""Explain why a head mutation is withheld for a non-triggering credential."""
source = mutation_token_source()
if source == "github-token":
credential_reason = (
"the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs"
return "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs"
if source not in WORKFLOW_STARTING_MUTATION_SOURCES:
return f"{mutation_token_label()} is not allowlisted as workflow-starting"

selected_token = (os.environ.get("GH_TOKEN") or "").strip()
workflow_token = (os.environ.get("SCHEDULER_WORKFLOW_TOKEN") or "").strip()
if not selected_token:
return f"{mutation_token_label()} is missing and therefore not proven workflow-starting"
if not workflow_token:
return (
"workflow GITHUB_TOKEN comparison evidence is missing, so the selected mutation "
"credential is not proven workflow-starting"
)
else:
credential_reason = (
f"the {mutation_token_label()}, which is not allowlisted as workflow-starting"
if selected_token == workflow_token:
return (
f"{mutation_token_label()} resolved to the workflow GITHUB_TOKEN, whose head "
"mutations never start new workflow runs"
)
return None


def head_mutation_credential_starts_workflows() -> bool:
"""Return whether the actual scheduler mutation token can start workflow runs."""
return head_mutation_credential_problem() is None


def non_triggering_head_mutation_reason(action: str) -> str:
"""Explain why a head mutation is withheld for a non-triggering credential."""
credential_reason = head_mutation_credential_problem()
if credential_reason is None:
raise RuntimeError("withheld-mutation messaging requires a non-triggering mutation credential")
return (
f"{action} withheld because the scheduler mutation credential is {credential_reason}, "
f"{action} withheld because {credential_reason}, "
"so the moved head would stay permanently "
"BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, "
"OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job"
Expand All @@ -495,15 +509,10 @@ def require_workflow_starting_mutation_credential(action: str) -> None:
raise RuntimeError(non_triggering_head_mutation_reason(action))


def head_mutation_credential_guidance_text() -> tuple[str, str]:
"""Return operator-facing summary and limit text for a withheld head mutation."""
if mutation_token_source() == "github-token":
return (
"The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.",
"Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.",
)
def head_mutation_credential_guidance_text(withheld_reason: str) -> tuple[str, str]:
"""Render operator guidance from the immutable credential decision."""
return (
f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.",
f"The scheduler withheld a head mutation. Recorded decision: {withheld_reason}",
"Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.",
)

Expand Down Expand Up @@ -654,7 +663,7 @@ def decision_guidance(decision: Decision) -> dict[str, Any] | None:
],
}
if parse_non_triggering_head_mutation_reason(decision.reason):
summary, automation_limit = head_mutation_credential_guidance_text()
summary, automation_limit = head_mutation_credential_guidance_text(decision.reason)
return {
"type": "head_mutation_credential_upgrade",
"token": mutation_token_label(),
Expand Down Expand Up @@ -5213,7 +5222,7 @@ def head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[
waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)]
if not waits:
return []
summary, automation_limit = head_mutation_credential_guidance_text()
summary, automation_limit = head_mutation_credential_guidance_text(waits[0].reason)
lines = ["", "### Head mutation withheld", "", summary, automation_limit]
lines.extend(
[
Expand All @@ -5232,6 +5241,8 @@ def parse_non_triggering_head_mutation_reason(reason: str) -> bool:
return (
"whose head mutations never start new workflow runs" in reason
or "which is not allowlisted as workflow-starting" in reason
or "is not allowlisted as workflow-starting" in reason
or "not proven workflow-starting" in reason
)


Expand Down Expand Up @@ -5429,16 +5440,28 @@ def summarize_action_error(exc: RuntimeError) -> str:

@contextlib.contextmanager
def declared_mutation_token_source(source: str) -> Iterator[None]:
"""Declare a scheduler mutation credential source for the enclosed block."""
previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE")
"""Declare coherent synthetic mutation-token evidence for offline self-tests."""
keys = (
"SCHEDULER_MUTATION_TOKEN_SOURCE",
"GH_TOKEN",
"SCHEDULER_WORKFLOW_TOKEN",
)
previous = {key: os.environ.get(key) for key in keys}
os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source
os.environ["SCHEDULER_WORKFLOW_TOKEN"] = "self-test-workflow-token"
os.environ["GH_TOKEN"] = (
"self-test-workflow-token"
if source == "github-token"
else "self-test-selected-mutation-token"
)
try:
yield
finally:
if previous is None:
os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None)
else:
os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous
for key, value in previous.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value


def self_test() -> None:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10820,3 +10820,49 @@ def fake_run_with_env(args, *, stdin=None, env=None):
"target-actions-token",
"target-actions-token",
]


@pytest.mark.parametrize(
("selected_token", "workflow_token", "message"),
(
("", "workflow-runner-token", "is missing"),
("selected-mutation-token", "", "comparison evidence is missing"),
("workflow-runner-token", "workflow-runner-token", "resolved to"),
),
)
def test_declared_workflow_starting_source_cannot_mask_runner_token_fallback(
monkeypatch,
selected_token,
workflow_token,
message,
):
"""A declared App/PAT source cannot hide a missing or workflow-token fallback."""
monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN")
monkeypatch.setenv("GH_TOKEN", selected_token)
monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", workflow_token)

assert not sched.head_mutation_credential_starts_workflows()
with pytest.raises(RuntimeError, match=message):
sched.require_workflow_starting_mutation_credential("update-branch")


def test_withheld_mutation_guidance_uses_recorded_reason_after_environment_changes(
monkeypatch,
):
"""A recorded wait decision cannot be rewritten by later credential changes."""
monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "github-token")
monkeypatch.setenv("GH_TOKEN", "workflow-runner-token")
monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token")
reason = sched.non_triggering_head_mutation_reason("branch update")

monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN")
monkeypatch.setenv("GH_TOKEN", "selected-mutation-token")
assert sched.head_mutation_credential_starts_workflows()

decision = sched.Decision(7, "wait", reason)
guidance = sched.decision_guidance(decision)
assert guidance is not None
assert "workflow GITHUB_TOKEN" in guidance["summary"]
assert "workflow GITHUB_TOKEN" in "\n".join(
sched.head_mutation_credential_upgrade_summary([decision])
)
Loading