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
10 changes: 4 additions & 6 deletions .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -488,16 +488,14 @@ jobs:
TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }}
TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }}
SCHEDULER_ACTIONS_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 }}
# Same-repository dispatch credential: when this scheduler runs inside
# ContextualWisdomLab/.github (the repository the required workflows are
# dispatched on), the runner token can dispatch them without any
# cross-repository PAT. The scheduler only uses it when
# GITHUB_REPOSITORY equals the dispatch repository.
# Central Actions read/control credential. Repository-dispatch POSTs
# use only the separately exchanged repository-scoped App token below.
SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}
SCHEDULER_REPOSITORY_DISPATCH_TOKEN: ${{ steps.scheduler_app_token.outputs.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_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github
SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }}
SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ steps.scheduler_app_token.outputs.available == 'true' && 'true' || 'false' }}
run: |
set -euo pipefail
case "$STALE_OPENCODE_MINUTES" in
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### Scheduler repository dispatch uses one canonical App identity

- `pr-review-merge-scheduler.yml` now passes the OIDC-exchanged OpenCode App token through a dedicated `SCHEDULER_REPOSITORY_DISPATCH_TOKEN` boundary. OpenCode and Strix `repository_dispatch` POSTs use only that credential and fail closed when the exchange is unavailable; they no longer fall back to `github.token`, a mutation token, or the central Actions-read token. The existing `SCHEDULER_DISPATCH_TOKEN` remains limited to `.github` Actions artifact reads/control, so the App token does not gain Actions permission. This preserves the receiver's actor=sender allowlist check while making both producer paths arrive as the same `opencode-agent[bot]` identity. Regression tests pin the workflow wiring, missing-token failure, token separation, target repository, exact payload, and shared admission budget. Refs #1927.

### Failed-check finding names the Strix sandbox instead of the gateway

- `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935.
Expand Down
23 changes: 20 additions & 3 deletions docs/doctoring/fork-head-review-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,32 @@ grant the workflow token sibling-repository access or make it authoritative for
branch mutation. This separates the repository-local rate-limit bucket from the
shared App installation bucket and follows GitHub's documented authentication
rate-limit scopes (GitHub, Inc., n.d.-c). The existing
`SCHEDULER_DISPATCH_TOKEN` remains the repository token because GitHub explicitly
permits `repository_dispatch` created with `GITHUB_TOKEN` to start a workflow
(GitHub, Inc., n.d.-d).
`SCHEDULER_DISPATCH_TOKEN` remains the repository token for central Actions
artifact reads and control calls. It is not a repository-dispatch credential.

The static regression requires both read and Actions-control expressions to
distinguish a same-repository target from a cross-repository target. The full
Python suite, 100% statement/branch/docstring gates, and the CI-budget Strix
shell gate remain authoritative before publication.

## Canonical repository-dispatch identity

Later production evidence showed that the two trusted producers reached the
receiver as different principals: the OpenCode leaf path used its OIDC-exchanged
repository-scoped App token and arrived as `opencode-agent[bot]`, while the
scheduler used `github.token` and arrived as `github-actions[bot]`. Maintaining
both identities in the receiver allowlist would preserve two mutable producer
boundaries for the same operation.

The scheduler therefore uses the same OIDC-exchanged OpenCode App token for
OpenCode and Strix repository-dispatch POSTs. The token is passed through the
dedicated `SCHEDULER_REPOSITORY_DISPATCH_TOKEN` environment boundary and has no
fallback: an unavailable exchange fails closed before dispatch. The scheduler's
Actions-read token and mutation credential remain separate. The receiving
workflows still require both `github.triggering_actor` and
`github.event.sender.login` to equal the one allowlisted principal; no ambient
token, new target, or authorization relaxation is introduced. Refs #1927.

## APA 7th references

GitHub, Inc. (n.d.-a). *REST API endpoints for pull requests*. GitHub Docs.
Expand Down
12 changes: 12 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@

스냅샷은 작성 시점의 open/close delta만 기록한다. 병합 판단에는 재사용하지 않는다.

### 1.4 중앙 repository-dispatch identity Gap

`GOV-DISPATCH-IDENTITY`는 중앙 OpenCode/Strix dispatch producer가 서로 다른
GitHub principal로 수신되는 설정 드리프트를 추적한다. Receiver의
actor=sender 동일성 검사와 정확한 target allowlist는 유지한다. Producer는
OIDC로 교환한 repository-scoped OpenCode App token 하나만 POST에 사용하고,
Actions read/control 및 PR mutation credential과 분리한다. App 교환 부재는
ambient `github.token`이나 PAT로 fallback하지 않고 fail closed한다. Source
GREEN 뒤에도 보호된 main, single-valued live allowlist, unchanged consumer
head의 terminal callback을 각각 fresh 확인하기 전에는 Gap을 닫지 않는다.
Owner: #1927.

## 2. PRD / TRD / UML 기준

### 2.1 PRD acceptance
Expand Down
64 changes: 48 additions & 16 deletions scripts/ci/pr_review_merge_scheduler_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,12 +803,13 @@ def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str:


def scheduler_dispatch_env() -> dict[str, str] | None:
"""Return an env override for central repository dispatch when configured.
"""Return an env override for central Actions reads when configured.

The OpenCode app installation has no Actions permission, so the mutation token
cannot create a repository dispatch. When the scheduler executes inside the
central repository receiving the event, the runner's own github.token is a
sufficient credential; the workflow passes it through SCHEDULER_DISPATCH_TOKEN.
cannot read the central workflow's artifacts. The scheduler's own github.token
can read those same-repository artifacts; the workflow passes it through the
legacy-named ``SCHEDULER_DISPATCH_TOKEN`` boundary. Repository-dispatch POSTs
use :func:`scheduler_repository_dispatch_env` instead.
"""
dispatch_token = os.environ.get("SCHEDULER_DISPATCH_TOKEN")
if not dispatch_token or dispatch_token == os.environ.get("GH_TOKEN"):
Expand All @@ -819,13 +820,42 @@ def scheduler_dispatch_env() -> dict[str, str] | None:


def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str:
"""Run a repository dispatch command with the dispatch token when configured."""
"""Run a central Actions read/control command with the runner token."""
env = scheduler_dispatch_env()
if env is None:
return run_github_actions(args, stdin=stdin)
return run_with_env(args, stdin=stdin, env=env)


def scheduler_repository_dispatch_env() -> dict[str, str]:
"""Return the repository-scoped OpenCode App credential for dispatch POSTs.

A repository dispatch is an authorization boundary, so it must never fall
back to the scheduler mutation credential, an ambient token, or the runner
token. The caller fails closed when the OIDC-backed App exchange was not
available for this run.
"""
app_token = os.environ.get("SCHEDULER_REPOSITORY_DISPATCH_TOKEN")
if not app_token:
raise RuntimeError(
"repository dispatch requires the repository-scoped OpenCode App token"
)
env = os.environ.copy()
env["GH_TOKEN"] = app_token
return env


def run_github_repository_dispatch(
args: Sequence[str], *, stdin: str | None = None
) -> str:
"""Create a repository dispatch using only the scoped App credential."""
return run_with_env(
args,
stdin=stdin,
env=scheduler_repository_dispatch_env(),
)


def split_repo(repo: str) -> tuple[str, str]:
"""Split an owner/name repository string into owner and repository name."""
try:
Expand Down Expand Up @@ -909,10 +939,12 @@ def repository_dispatch_wait_reason(repo: str, workflow: str) -> str | None:
if dispatch_repo == target_repo or env_flag_enabled("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH"):
return None
execution_repo = (os.environ.get("GITHUB_REPOSITORY") or "").strip()
if os.environ.get("SCHEDULER_DISPATCH_TOKEN") and execution_repo == dispatch_repo:
# The dispatch targets the repository this scheduler run executes in and the
# workflow provided a dispatch-capable runner token for it, so no
# cross-repository credential is needed.
if (
os.environ.get("SCHEDULER_REPOSITORY_DISPATCH_TOKEN")
and execution_repo == dispatch_repo
):
# The dispatch targets the repository this scheduler run executes in and
# the workflow supplied its repository-scoped App token.
return None
return (
f"{workflow} dispatch waits for central required workflow materialization; "
Expand Down Expand Up @@ -1168,17 +1200,17 @@ def gh_api_json(path: str) -> Any:


def gh_api_json_via_dispatch_token(path: str) -> Any:
"""Run a GitHub REST API GET via the central-repository dispatch credential.
"""Run a GitHub REST API GET via the central Actions-read credential.

The OpenCode app installation has no Actions permission (see
:func:`scheduler_dispatch_env`), and the target-repository read
credential (:func:`gh_api_json`) is not guaranteed to have it either for
a cross-repository dispatch. A read against ``.github``'s own Actions
artifacts -- which always host the central draft-review-request marker
regardless of which repository the PR belongs to -- must use the same
central-repository dispatch credential already used for creating a
``repository_dispatch`` there, not the target-repository read
credential.
regardless of which repository the PR belongs to -- must use the central
runner token, not the target-repository read credential or the separate
App token used only for ``repository_dispatch`` POSTs. The function name
is retained as an internal compatibility boundary.
"""

return json.loads(run_github_dispatch(["gh", "api", path]))
Expand Down Expand Up @@ -3727,7 +3759,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr
client_payload["required_run_id"] = required_run_id
if not live_dispatch_head_matches(target_repo, pr):
return "stale_head"
run_github_dispatch(
run_github_repository_dispatch(
[
"gh",
"api",
Expand Down Expand Up @@ -3816,7 +3848,7 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry
base_ref, base_sha, head_sha = validated_pr_dispatch_fields(pr)
if not live_dispatch_head_matches(target_repo, pr):
return "stale_head"
run_github_dispatch(
run_github_repository_dispatch(
[
"gh",
"api",
Expand Down
Loading
Loading