diff --git a/.github/workflows/agent-mention-router-quality-ci.yml b/.github/workflows/agent-mention-router-quality-ci.yml index 9c36a89119..17afc28780 100644 --- a/.github/workflows/agent-mention-router-quality-ci.yml +++ b/.github/workflows/agent-mention-router-quality-ci.yml @@ -11,6 +11,8 @@ on: - "docs/automation/review-agent-comment-invocation.md" - "scripts/ci/agent_mention_router.py" - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/exchange_opencode_app_token.sh" + - "scripts/ci/opencode_repository_dispatch_targets.json" - "tests/test_agent_mention_*.py" - "tests/test_pr_review_fix_scheduler_coverage.py" - "requirements-opencode-review-ci-hashes.txt" @@ -24,6 +26,8 @@ on: - "docs/automation/review-agent-comment-invocation.md" - "scripts/ci/agent_mention_router.py" - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/exchange_opencode_app_token.sh" + - "scripts/ci/opencode_repository_dispatch_targets.json" - "tests/test_agent_mention_*.py" - "tests/test_pr_review_fix_scheduler_coverage.py" - "requirements-opencode-review-ci-hashes.txt" @@ -111,4 +115,5 @@ jobs: scripts/ci/agent_mention_router.py \ scripts/ci/agent_mention_sweep.py python -m compileall -q scripts/ci tests + bash -n scripts/ci/exchange_opencode_app_token.sh git diff --check "$CHANGE_DIFF_RANGE" diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml index 63ec8e3231..2626bd038e 100644 --- a/.github/workflows/agent-mention-router.yml +++ b/.github/workflows/agent-mention-router.yml @@ -3,6 +3,14 @@ name: Review Agent Mention Router on: issue_comment: types: [created] + workflow_call: + inputs: + pull_request_number: + required: true + type: number + source_comment_id: + required: true + type: number schedule: - cron: "*/5 * * * *" @@ -35,7 +43,7 @@ jobs: actions: read contents: write issues: write - pull-requests: read + pull-requests: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true GH_TOKEN: ${{ github.token }} @@ -67,95 +75,164 @@ jobs: python3 -u scripts/ci/agent_mention_router.py --event-path "${RUNNER_TEMP}/agent-mention-event.json" - sweep-organization-agent-mentions: + route-native-agent-mention: if: >- - github.repository == 'ContextualWisdomLab/.github' - && github.event_name == 'schedule' - concurrency: - group: review-agent-mention-router-sweep-${{ github.repository }} - cancel-in-progress: false + github.event_name == 'workflow_call' + && github.repository_owner == 'ContextualWisdomLab' runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 5 + concurrency: + group: review-agent-mention-router-central-${{ github.repository }}-${{ inputs.pull_request_number }} + cancel-in-progress: true permissions: - actions: read - contents: write + contents: read id-token: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} - LOOKBACK_HOURS: ${{ vars.AGENT_MENTION_LOOKBACK_HOURS || '168' }} - MAX_DISPATCHES: ${{ vars.AGENT_MENTION_MAX_DISPATCHES || '20' }} - TIME_BUDGET_SECONDS: ${{ vars.AGENT_MENTION_TIME_BUDGET_SECONDS || '480' }} - DRY_RUN: "false" steps: - - name: Exchange OpenCode app token for sibling-repository comments - id: sweep_app_token + - name: Resolve exact called-workflow source + id: trusted_source env: OIDC_AUDIENCE: opencode-github-action - OPENCODE_API_BASE_URL: https://api.opencode.ai - USER_TOKEN_CONFIGURED: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} run: | set -euo pipefail - mark_unavailable() { - echo "available=false" >>"$GITHUB_OUTPUT" - } - if [ "$USER_TOKEN_CONFIGURED" = "true" ]; then - echo "A configured cross-repository user token takes precedence." - mark_unavailable - exit 0 - fi - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "OpenCode app token exchange unavailable: OIDC request environment is missing." - mark_unavailable - exit 0 - fi - request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" - separator="&" + request_url="$ACTIONS_ID_TOKEN_REQUEST_URL" + separator="?" case "$request_url" in - *\?*) ;; - *) separator="?" ;; + *\?*) separator="&" ;; esac - if ! oidc_response="$( + oidc_response="$( curl -fsS --connect-timeout 10 --max-time 30 \ -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ "${request_url}${separator}audience=${OIDC_AUDIENCE}" - )"; then - echo "OpenCode app token exchange unavailable: OIDC token request did not complete." - mark_unavailable - exit 0 - fi + )" oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" if [ -z "$oidc_token" ]; then - echo "OpenCode app token exchange unavailable: OIDC token response was empty." - mark_unavailable - exit 0 + echo "::error::Called-workflow identity token was empty." + exit 1 fi - if ! token_response="$( - curl -fsS --connect-timeout 10 --max-time 30 \ - -X POST \ - -H "Authorization: Bearer ${oidc_token}" \ - "${OPENCODE_API_BASE_URL}/exchange_github_app_token" - )"; then - echo "OpenCode app token exchange unavailable: app token request did not complete." - mark_unavailable - exit 0 + workflow_ref="$( + printf '%s' "$oidc_token" | python3 -c \ + 'import base64,json,sys; part=sys.stdin.read().split(".")[1]; print(json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))["job_workflow_ref"])' + )" + if [[ ! "$workflow_ref" =~ ^ContextualWisdomLab/\.github/\.github/workflows/agent-mention-router\.yml@([0-9a-f]{40})$ ]]; then + echo "::error::Native caller must pin the central router by exact commit SHA." + exit 1 + fi + echo "sha=${BASH_REMATCH[1]}" >>"$GITHUB_OUTPUT" + + - name: Check out exact called-workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ContextualWisdomLab/.github + ref: ${{ steps.trusted_source.outputs.sha }} + persist-credentials: false + + - name: Verify exact called-workflow checkout + env: + EXPECTED_SHA: ${{ steps.trusted_source.outputs.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + test -f .github/workflows/agent-mention-router.yml + test ! -L .github/workflows/agent-mention-router.yml + test -f scripts/ci/opencode_repository_dispatch_targets.json + test ! -L scripts/ci/opencode_repository_dispatch_targets.json + + - name: Exchange OpenCode app token + id: native_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: bash scripts/ci/exchange_opencode_app_token.sh + + - name: Route exact native comment event + env: + TARGET_REPOSITORY: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + SOURCE_COMMENT_ID: ${{ inputs.source_comment_id }} + run: | + set -euo pipefail + if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Native mention caller repository is invalid." + exit 1 + fi + if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]] || [[ ! "$SOURCE_COMMENT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Native mention pull request or comment identity is invalid." + exit 1 + fi + router_token="${OPENCODE_APP_TOKEN:-}" + if [ -z "$router_token" ]; then + echo "::error::Native mention routing requires the OpenCode app credential." + exit 1 + fi + echo "::add-mask::$router_token" + export GH_TOKEN="$router_token" + export TARGET_REPOSITORY_TOKEN="$router_token" + export AGENT_DISPATCH_TOKEN="$router_token" + OPENCODE_REPOSITORY_DISPATCH_TARGETS="$( + jq -er \ + 'if (.targets | type) == "array" and (.targets | length) > 0 and all(.targets[]; type == "string") then .targets | join(",") else error("invalid target mirror") end' \ + scripts/ci/opencode_repository_dispatch_targets.json + )" + if [ -z "$OPENCODE_REPOSITORY_DISPATCH_TARGETS" ]; then + echo "::error::Central OpenCode repository allowlist mirror is unavailable." + exit 1 fi - app_token="$(jq -r '.token // empty' <<<"$token_response")" - if [ -z "$app_token" ]; then - echo "OpenCode app token exchange unavailable: app token response was empty." - mark_unavailable - exit 0 + export OPENCODE_REPOSITORY_DISPATCH_TARGETS + pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}")" + source_comment_json="$(gh api "repos/${TARGET_REPOSITORY}/issues/comments/${SOURCE_COMMENT_ID}")" + expected_issue_url="https://api.github.com/repos/${TARGET_REPOSITORY}/issues/${PULL_REQUEST_NUMBER}" + actual_issue_url="$(jq -r '.issue_url // empty' <<<"$source_comment_json")" + if [ "$actual_issue_url" != "$expected_issue_url" ]; then + echo "::error::Native mention comment is not bound to the requested pull request." + exit 1 fi - echo "::add-mask::$app_token" - echo "available=true" >>"$GITHUB_OUTPUT" - echo "SWEEP_APP_TOKEN=$app_token" >>"$GITHUB_ENV" + jq -n \ + --arg repository "$TARGET_REPOSITORY" \ + --argjson number "$PULL_REQUEST_NUMBER" \ + --argjson comment "$source_comment_json" \ + --argjson pull_request "$pull_request_json" \ + '{issue: {number: $number, pull_request: {url: $pull_request.url}}, comment: $comment, repository: {full_name: $repository}, pull_request: $pull_request}' \ + >"${RUNNER_TEMP}/agent-mention-event.json" + python3 -u scripts/ci/agent_mention_router.py \ + --event-path "${RUNNER_TEMP}/agent-mention-event.json" + sweep-organization-agent-mentions: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.event_name == 'schedule' + concurrency: + group: review-agent-mention-router-sweep-${{ github.repository }} + cancel-in-progress: false + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read + contents: write + id-token: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + LOOKBACK_HOURS: ${{ vars.AGENT_MENTION_LOOKBACK_HOURS || '168' }} + MAX_DISPATCHES: ${{ vars.AGENT_MENTION_MAX_DISPATCHES || '20' }} + TIME_BUDGET_SECONDS: ${{ vars.AGENT_MENTION_TIME_BUDGET_SECONDS || '480' }} + DRY_RUN: "false" + steps: - name: Check out trusted central router uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false + - name: Exchange OpenCode app token for sibling-repository comments + id: sweep_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + USER_TOKEN_CONFIGURED: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} + run: bash scripts/ci/exchange_opencode_app_token.sh + - name: Sweep recent organization PR comments env: PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} @@ -170,7 +247,7 @@ jobs: TARGET_REPOSITORY_TOKEN="$OPENCODE_APPROVE_TOKEN" TARGET_REPOSITORY_SOURCE="organization" else - TARGET_REPOSITORY_TOKEN="${SWEEP_APP_TOKEN:-}" + TARGET_REPOSITORY_TOKEN="${OPENCODE_APP_TOKEN:-}" TARGET_REPOSITORY_SOURCE="${TARGET_REPOSITORY_TOKEN:+installation}" fi export TARGET_REPOSITORY_TOKEN diff --git a/AGENTS.md b/AGENTS.md index 688d43bdfc..9da8f9ea35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,24 @@ The materialization contract is also covered by [`docs/doctoring/exact-artifact- target repository, and pull request number with `cancel-in-progress: true`; do not include the head SHA, because that prevents a new head from cancelling its predecessor. Non-PR triggers need an explicit collision-safe fallback. +- Sibling-repository review-agent comments use a thin native `issue_comment` + caller pinned to the central `agent-mention-router.yml` commit. The reusable + router must derive that called-workflow SHA from GitHub's OIDC + `job_workflow_ref` claim before checkout; `github.workflow_sha` identifies the + top-level caller during `workflow_call`, and caller-supplied source refs are + not trust evidence. Reusable-workflow `vars` also resolve in caller context; + read the reviewed central allowlist mirror from the verified checkout instead + of trusting a same-named caller variable. Reusable-workflow concurrency must + use the central workflow's literal namespace rather than `github.workflow`, + which resolves to the caller name and can make the called job cancel its own + caller. Keep the scheduled organization sweep only as a bounded + missed-event fallback until every target has a verified native receipt path. + See [`docs/automation/review-agent-comment-invocation.md`](docs/automation/review-agent-comment-invocation.md). +- PR conversation acknowledgement requires job-scoped `pull-requests: write`; + `issues: write` plus `pull-requests: read` can still return HTTP 403 for both + reactions and receipt comments. A missing durable receipt must fail the + router job after dispatch state is preserved; do not emit a warning and mark + the request green. - Put concurrency at workflow scope when queued jobs must be coalesced before a runner is admitted. Job-level concurrency cannot relieve a saturated runner queue because it is evaluated only after job admission. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d394488f3..2bee143850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +### Native review-agent comments no longer wait for the organization sweep + +- Added a native reusable entry to the central review-agent mention router so + sibling repositories can forward one exact PR comment immediately instead of + waiting for a delayed organization schedule. The router reloads live PR and + comment objects, binds them through the existing exact-name artifact ledger, + and accepts only an exact-SHA central invocation proven by GitHub's OIDC + `job_workflow_ref` claim. Trigger-aware concurrency cancels only the older + invocation for the same workflow, repository, and PR. The organization sweep + remains a bounded missed-event fallback during caller rollout. +- Fixed local PR acknowledgement authority after run `34324306522` dispatched + review work but returned HTTP 403 for both reaction and receipt publication: + the local job now grants job-scoped pull-request write permission, cosmetic + reaction failure no longer emits a warning annotation, and missing receipt + publication fails the run while retaining the durable dispatch claim. +- Isolated reusable-router concurrency from caller context. The central job now + uses its own literal workflow namespace instead of `github.workflow`, so + `cancel-in-progress` cannot cancel the thin caller that invoked it. + ### Graphify review graph uses one wheel-validated OpenCode policy - Added the exact-head Graphify review graph and its local MCP handshake to the diff --git a/CLAUDE.md b/CLAUDE.md index 3988811ded..91ba11b348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,19 @@ Details: `docs/pr-review-and-merge-procedure.md` and `PR_GOVERNANCE_AUDIT.md`. `security-scan.yml`, `sast-semgrep.yml`, `secret-scan.yml`, `codeql-pr.yml`, `osv-scanner-pr.yml`, `scorecard-*.yml`, SBOM workflows), and reusable `workflow_call` workflows sibling repos call (`deploy-pages.yml`, `pr-review-fix-scheduler.yml`). +- `agent-mention-router.yml` also exposes the central native comment route as a + reusable workflow. Product repositories own only a thin `issue_comment` + caller pinned to an exact central commit; the reusable job verifies its own + called-workflow SHA from the GitHub OIDC claim before checking out central + code. Its concurrency group uses the central router namespace explicitly; + `github.workflow` is caller context during `workflow_call` and must not make + the reusable job collide with its caller. The scheduled organization sweep + is recovery, not the primary receipt path. +- Mention receipts are required observable evidence. The local router needs + job-scoped `pull-requests: write`; if receipt publication fails after a + dispatch, the run fails closed while the ledger prevents duplicate work on a + retry. Cosmetic reaction failure alone remains non-blocking and emits no + warning annotation. - `scripts/ci/` — Python/bash helpers the workflows execute (schedulers, review normalization and gates, sandboxed verification, prompt template rendering). `tests/` covers them. - `opencode.jsonc` + `ci-review-prompt.md` + `code-reviewer-prompt.md` — the OpenCode reviewer diff --git a/docs/automation/review-agent-comment-invocation.md b/docs/automation/review-agent-comment-invocation.md index 926249b563..4ad62d4bf4 100644 --- a/docs/automation/review-agent-comment-invocation.md +++ b/docs/automation/review-agent-comment-invocation.md @@ -1,6 +1,6 @@ # Review-agent comment invocation -Updated: 2026-09-01 +Updated: 2026-09-09 ## Purpose @@ -15,10 +15,11 @@ The router never checks out or executes pull-request-controlled code. It reads l GitHub organization ruleset workflows support `pull_request`, `pull_request_target`, and `merge_group`, but not `issue_comment`. Separately, an `issue_comment` workflow runs only when that workflow file exists on the commented repository's default branch. Therefore, a workflow stored only in the central `.github` repository cannot directly receive comments created in sibling repositories. -The implementation uses two bounded paths: +The implementation uses three bounded paths: 1. **Local fast path.** Comments on `ContextualWisdomLab/.github` trigger `issue_comment` immediately. -2. **Organization sweep.** Every five minutes, the central workflow enumerates repositories visible to its cross-repository credential, finds recently updated open PRs and recent comments, validates trusted exact mentions, and consults the central exact-name Actions artifact ledger before queuing work. +2. **Native sibling path.** A product repository's tiny `issue_comment` caller forwards only the PR number and source comment ID to the central reusable workflow pinned at an exact commit. The central job reloads both live GitHub objects and uses the same router and ledger as the local path. +3. **Organization sweep fallback.** The scheduled central workflow enumerates repositories visible to its cross-repository credential, finds recently updated open PRs and recent comments, validates trusted exact mentions, and consults the ledger before queuing work. GitHub schedules can be delayed or dropped under organization-wide Actions pressure, so this path is recovery rather than receipt latency authority. Each requested agent receives a deterministic invocation key containing the target repository, PR number, exact head SHA, base branch, requested agent, source comment ID, and requesting actor. Each agent-specific wrapper reconstructs the same canonical JSON from its validated payload, hashes it with SHA-256, and compares the result in constant time with the supplied key. Altering any bound field while retaining a syntactically valid key therefore fails closed. @@ -28,11 +29,11 @@ Wrapper workflows use the verified key in their non-cancelling concurrency group Target-repository acknowledgement comments and reactions are user-experience signals only. They are not dispatch authority because repository writers, bot identities, or credential rotation could otherwise forge or invalidate a marker. A failed acknowledgement cannot cause completed agent work to be redispatched. -When a live claim exists without a visible receipt comment, the router republishes the acknowledgement without forwarding the request again; reaction failures are warnings and do not block the durable comment. +When a live claim exists without a visible receipt comment, the router republishes the acknowledgement without forwarding the request again. A reaction failure is a plain cosmetic notice and does not block the durable comment. Receipt publication fails the job after preserving dispatch state, so a missing user-visible receipt cannot hide behind a green run. A user or fine-grained token enumerates organization repositories. When the OpenCode GitHub App installation token is the available credential, the sweep instead uses GitHub's installation-repositories endpoint, which returns only repositories accessible to that installation. This avoids depending on an organization-issues endpoint whose documented fine-grained token support is user-token-oriented. -This preserves the central MSA boundary without copying privileged workflow code into every product repository. +This preserves the central MSA boundary without copying privileged workflow code into every product repository. A native caller contains no routing implementation or credential exchange. ## Trust and permission boundary @@ -40,8 +41,24 @@ This preserves the central MSA boundary without copying privileged workflow code - Bot comments, ordinary contributors, issue comments outside PRs, closed PRs, malformed metadata, and lookalike handles fail closed. - Historical, duplicate, rejected, or already-ledgered requests do not consume the bounded new-work dispatch budget. - The workflow default token is read-only. -- The local routing job receives job-scoped `actions: read`, `contents: write`, `issues: write`, and `pull-requests: read`. +- The local routing job receives job-scoped `actions: read`, `contents: write`, `issues: write`, and `pull-requests: write`. GitHub classifies writes on PR conversations under pull-request permission; `pull-requests: read` produced HTTP 403 for both reaction and receipt publication in run `34324306522` despite `issues: write`. - The organization sweep receives job-scoped `actions: read`, `contents: write`, and `id-token: write`. +- The native reusable job receives only `contents: read` and `id-token: write`. + Before checkout it requests a GitHub OIDC token, validates the exact + `ContextualWisdomLab/.github/.github/workflows/agent-mention-router.yml@<40-hex-SHA>` + `job_workflow_ref`, and checks out that immutable central commit. This is the + called-workflow identity during `workflow_call`; `github.workflow_sha` is the + caller's workflow SHA and must not select privileged central code. +- The native reusable job uses + `review-agent-mention-router-central-${repository}-${pull_request}` as its + concurrency namespace. `github.workflow` resolves to the caller workflow in + a reusable invocation; using it here can collide with and cancel the caller + before the route publishes a receipt. +- Reusable-workflow `vars` come from caller context. The native job therefore + reads the reviewed `opencode_repository_dispatch_targets.json` mirror from + the verified central checkout and fails closed if that policy is malformed or + absent. The existing drift check keeps the mirror aligned with the live + central repository variable. - The two agent-specific wrapper workflows receive only job-scoped `actions: read` and `contents: write`; their workflow defaults remain `contents: read`. - `actions: read` permits exact-name artifact inventory checks. Artifact upload uses the workflow artifact service and is pinned to immutable `actions/upload-artifact` v7.0.1. - `contents: write` is intentionally retained only on jobs that call GitHub's create-repository-dispatch endpoint. GitHub documents that endpoint as requiring Contents repository permission at write level. Removing it would disable the bounded central dispatch path; broad workflow-default write access is not granted. @@ -57,7 +74,7 @@ This preserves the central MSA boundary without copying privileged workflow code - `AGENT_MENTION_LOOKBACK_HOURS`: default `168`, allowed range 1–720. - `AGENT_MENTION_MAX_DISPATCHES`: default `20`, allowed range 1–100. The bound counts source requests that actually queue at least one new agent, not historical no-ops. - Durable invocation claims use 30-day artifact retention. A new source comment creates a new invocation key when an intentional retry is required. -- Operators request immediate work by writing an exact trusted mention on the target pull request; otherwise, the five-minute protected-default-branch sweep processes it. +- Operators request immediate work by writing an exact trusted mention on a target pull request whose default branch contains the native caller. Repositories not yet migrated rely on the protected-default-branch sweep fallback. - The sweep fails visibly when no cross-repository credential is available. - `PR_REVIEW_MERGE_TOKEN` or `OPENCODE_APPROVE_TOKEN` takes precedence. Otherwise, the workflow exchanges its OIDC token for the existing OpenCode installation token and enumerates that installation's repositories. @@ -65,14 +82,31 @@ This preserves the central MSA boundary without copying privileged workflow code The permanent quality workflow runs the deterministic router, sweep, exact-name artifact ledger, wrapper, receipt-authority, and workflow-contract suites under Python 3.14 and requires 100% production statement coverage, branch coverage, and public docstring coverage. It also compiles the Python files and checks the final diff for whitespace errors. A permanent regression contract also rejects the transient PR-specific branch-writer workflows and repair helpers used during development, so they cannot ship with the control plane. +For rollout, pin each caller to the protected merge commit, use the concurrency +group `${{ github.workflow }}-${{ github.repository }}-${{ github.event.issue.number }}` +with `cancel-in-progress: true`, and verify an exact-head receipt comment and +central ledger artifact from a real browser-visible PR comment. Do not remove +the sweep until the target inventory shows native callers and successful +receipts for every repository. A caller branch, tag, unmerged central SHA, DOM +assertion, or workflow success without the request receipt is not activation +evidence. + ### Activation gate The router is inactive until its workflows and helper code are merged into the protected default branch. A materialization, predecessor, cancelled, queued, or stale-head run is not activation evidence. Production activation requires the exact final head to pass the permanent quality workflow, security and supply-chain checks, current-head automated review, an independent approval, unresolved-thread policy, and branch protection without bypass. -Rollback is deletion of the four mention-router workflows, the two Python helpers, and their focused tests. Existing Noema and OpenCode review workflows remain independently invocable and authoritative; the router does not own reviewer identity, credentials, verdict acceptance, approval, merge, or release. +Rollback first removes product callers, then removes the reusable entry. The +sweep remains available during that rollback. Existing Noema and OpenCode +review workflows remain independently invocable and authoritative; the router +does not own reviewer identity, credentials, verdict acceptance, approval, +merge, or release. ## References +GitHub. (n.d.). *OIDC reference*. GitHub Docs. Retrieved September 9, 2026, from https://docs.github.com/en/actions/reference/security/oidc + +GitHub. (n.d.). *Reuse workflows*. GitHub Docs. Retrieved September 9, 2026, from https://docs.github.com/en/actions/how-tos/sharing-automations/reusing-workflows + GitHub. (n.d.). *Available rules for rulesets*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets GitHub. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/enterprise-cloud@latest/actions/reference/workflows-and-actions/events-that-trigger-workflows diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 05273d2e2f..ae2e59c1ae 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3381,3 +3381,55 @@ 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.** + +## 2026-09-09 review-agent mention receipt latency: native reusable owner path + +**Observed gap.** The central mention workflow is configured on a five-minute +schedule, but recent protected-main runs arrived roughly two to five hours +apart while the organization was near its shared Actions ceiling. A successful +sweep run therefore proves only that one bounded scan completed; it does not +prove that a particular `@opencode-agent` comment was visited. This left +current-head review requests waiting behind unrelated organization traffic. + +**Owner repair.** The existing `agent-mention-router.yml` now exposes a +`workflow_call` entry. A sibling repository can receive `issue_comment` +natively and pass only the PR number and comment ID. The central job reloads +the live objects and reuses the existing eligibility, exact-head binding, +dispatch, acknowledgement, and exact-name artifact ledger. No second router, +polling sleep, or repository-wide queue scanner was added. + +**Trust and load controls.** The reusable route accepts only a central workflow +invoked at an exact commit proven by GitHub's OIDC `job_workflow_ref`; it does +not trust a caller-supplied ref or `github.workflow_sha`, which names the +top-level caller during `workflow_call`. It also reads the reviewed central +allowlist mirror from the verified checkout because reusable-workflow `vars` +belong to caller context. Its concurrency key uses the literal central-router +workflow namespace, repository, and PR, with cancellation enabled. +`github.workflow` is deliberately excluded because it resolves to the caller +and can make the reusable job cancel that caller. A newer request for one PR +cannot cancel another PR or workflow. The sweep remains only as bounded +missed-event recovery while exact-SHA callers roll out. + +**Acceptance KPI.** Central focused contracts and Action syntax must pass; the +protected PR head must pass required checks and independent review; then one +real sibling PR comment must produce a browser-visible exact-head receipt and +central ledger artifact without waiting for a scheduled run. Sweep removal is +deferred until every supported repository has that native path and receipt +evidence. + +**Runtime correction.** Local issue-comment run `34324306522` dispatched the +exact request but its reaction and receipt writes both returned HTTP 403. The +job had `issues: write` while explicitly limiting `pull-requests` to read, so a +green run concealed missing user-visible evidence. The local job now grants +job-scoped `pull-requests: write`; reaction failure remains cosmetic without a +warning annotation, while receipt failure makes the run nonzero after dispatch +state is preserved. A retry can heal the receipt without forwarding duplicate +work through the existing exact-name ledger. + +**Reusable concurrency correction.** Review of pre-repair head `216a3fe4` +found that the called job used `${{ github.workflow }}` in its group. In +`workflow_call` context that value names the top-level caller, so a caller using +the required workflow/repository/PR group could be cancelled by the central +job it invoked. The central job now uses the literal +`review-agent-mention-router-central` namespace; a focused contract rejects the +caller-context expression. diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index 46332cfc2f..2a4da65a05 100755 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -662,10 +662,10 @@ def dispatch_request( ], input_payload={"content": "eyes"}, ) - except Exception as exc: # noqa: BLE001 - acknowledgement is cosmetic + except Exception as exc: # noqa: BLE001 - reaction is cosmetic message = " ".join(str(exc).split()) or exc.__class__.__name__ print( - "::warning::Agent mention acknowledgement reaction failed; " + "Agent mention acknowledgement reaction unavailable; " f"durable dispatch state is preserved: {message[:1000]}" ) status_parts: list[str] = [] @@ -697,12 +697,12 @@ def dispatch_request( ], input_payload={"body": acknowledgement}, ) - except Exception as exc: # noqa: BLE001 - acknowledgement is cosmetic + except Exception as exc: # noqa: BLE001 - preserve the source failure message = " ".join(str(exc).split()) or exc.__class__.__name__ - print( - "::warning::Agent mention acknowledgement comment failed; " + raise RuntimeError( + "Agent mention receipt publication did not complete; " f"durable dispatch state is preserved: {message[:1000]}" - ) + ) from exc else: if ledger_artifact_cache is not None: ledger_artifact_cache[acknowledgement_cache_key] = True diff --git a/scripts/ci/exchange_opencode_app_token.sh b/scripts/ci/exchange_opencode_app_token.sh new file mode 100644 index 0000000000..3015738cc5 --- /dev/null +++ b/scripts/ci/exchange_opencode_app_token.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" +} + +if [ "${USER_TOKEN_CONFIGURED:-false}" = "true" ]; then + echo "A configured cross-repository user token takes precedence." + mark_unavailable + exit 0 +fi +if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 +fi + +request_url="$ACTIONS_ID_TOKEN_REQUEST_URL" +separator="?" +case "$request_url" in + *\?*) separator="&" ;; +esac +if ! oidc_response="$( + curl -fsS --connect-timeout 10 --max-time 30 \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" +)"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 +fi +oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" +if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 +fi +if ! token_response="$( + curl -fsS --connect-timeout 10 --max-time 30 \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" +)"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 +fi +app_token="$(jq -r '.token // empty' <<<"$token_response")" +if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 +fi +echo "::add-mask::$app_token" +echo "available=true" >>"$GITHUB_OUTPUT" +echo "OPENCODE_APP_TOKEN=$app_token" >>"$GITHUB_ENV" diff --git a/tests/test_agent_mention_acknowledgement_recovery.py b/tests/test_agent_mention_acknowledgement_recovery.py index 231a54fd2d..f8d50c2efb 100644 --- a/tests/test_agent_mention_acknowledgement_recovery.py +++ b/tests/test_agent_mention_acknowledgement_recovery.py @@ -7,6 +7,8 @@ from pathlib import Path from types import ModuleType +import pytest + ROOT = Path(__file__).resolve().parents[1] MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" @@ -133,34 +135,31 @@ def test_reaction_failure_does_not_hide_successful_dispatch(capsys) -> None: assert len(dispatch_mutations(central)) == 1 assert len(acknowledgement_comments(target)) == 1 - assert "::warning::" in capsys.readouterr().out + output = capsys.readouterr().out + assert "acknowledgement reaction unavailable" in output + assert "::warning::" not in output -def test_acknowledgement_comment_failure_does_not_hide_dispatch(capsys) -> None: - """A cosmetic comment failure preserves dispatch and permits a retry.""" +def test_acknowledgement_comment_failure_fails_closed_after_dispatch() -> None: + """A missing receipt fails the run while preserving dispatch for retry.""" module = load_module() central = FakeClient() target = FakeClient(fail_comment=True) cache = {} - assert module.dispatch_request( - request(module), - target_client=target, - dispatch_client=central, - opencode_allowlist=frozenset({"ContextualWisdomLab/.github"}), - ledger_artifact_cache=cache, - ) == ("@opencode-agent",) + with pytest.raises(RuntimeError, match="receipt publication did not complete"): + module.dispatch_request( + request(module), + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset({"ContextualWisdomLab/.github"}), + ledger_artifact_cache=cache, + ) assert len(dispatch_mutations(central)) == 1 assert len(acknowledgement_comments(target)) == 1 assert not any(key.startswith("acknowledgement:") for key in cache) - assert ( - capsys.readouterr().out - == "::warning::Agent mention acknowledgement comment failed; " - "durable dispatch state is preserved: comment publication failed\n" - ) - target.fail_comment = False assert module.dispatch_request( request(module), diff --git a/tests/test_agent_mention_workflow_contract.py b/tests/test_agent_mention_workflow_contract.py index c5fc4cae54..13e118a60c 100644 --- a/tests/test_agent_mention_workflow_contract.py +++ b/tests/test_agent_mention_workflow_contract.py @@ -16,12 +16,13 @@ def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> text = WORKFLOW.read_text(encoding="utf-8") header, jobs = text.split("\njobs:\n", 1) assert "issue_comment:" in header + assert "workflow_call:" in header assert 'cron: "*/5 * * * *"' in header assert "workflow_dispatch:" not in header assert "permissions:\n contents: read" in header assert "contents: write" not in header - assert text.count("runs-on: ubuntu-24.04") == 2 - assert text.count(CHECKOUT_PIN) == 2 + assert text.count("runs-on: ubuntu-24.04") == 3 + assert text.count(CHECKOUT_PIN) == 3 assert "ubuntu-latest" not in text assert "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" not in text @@ -32,7 +33,7 @@ def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> "actions: read", "contents: write", "issues: write", - "pull-requests: read", + "pull-requests: write", ): assert f" {permission}" in local assert "ref: ${{ github.event.repository.default_branch }}" in local @@ -51,6 +52,60 @@ def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> assert "agent_mention_sweep.py" in sweep +def test_native_reusable_route_reuses_exact_called_source_and_live_github_data() -> None: + """Sibling callers pass identities while the central route reloads live objects.""" + + text = WORKFLOW.read_text(encoding="utf-8") + native = text.split("\n route-native-agent-mention:\n", 1)[1].split( + "\n sweep-organization-agent-mentions:\n", 1 + )[0] + + assert "github.event_name == 'workflow_call'" in native + assert "github.repository_owner == 'ContextualWisdomLab'" in native + assert ( + "group: review-agent-mention-router-central-${{ github.repository }}-" + "${{ inputs.pull_request_number }}" + ) in native + assert "group: ${{ github.workflow }}-" not in native + assert "cancel-in-progress: true" in native + assert 'json.loads(base64.urlsafe_b64decode' in native + assert '["job_workflow_ref"]' in native + assert "agent-mention-router\\.yml@([0-9a-f]{40})" in native + assert "repository: ContextualWisdomLab/.github" in native + assert "ref: ${{ steps.trusted_source.outputs.sha }}" in native + assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in native + assert "test ! -L scripts/ci/opencode_repository_dispatch_targets.json" in native + assert "${{ job.workflow_repository }}" not in native + assert "${{ job.workflow_sha }}" not in native + assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}"' in native + assert 'gh api "repos/${TARGET_REPOSITORY}/issues/comments/${SOURCE_COMMENT_ID}"' in native + assert "scripts/ci/opencode_repository_dispatch_targets.json" in native + assert "Central OpenCode repository allowlist mirror is unavailable" in native + assert '.targets | join(",")' in native + assert "OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars." not in native + assert "PR_REVIEW_MERGE_TOKEN" not in native + assert "OPENCODE_APPROVE_TOKEN" not in native + assert 'router_token="${OPENCODE_APP_TOKEN:-}"' in native + assert "actual_issue_url" in native + assert "agent_mention_router.py" in native + assert "sleep " not in native + assert "agent_mention_sweep.py" not in native + + +def test_app_token_exchange_is_one_shared_shell_contract() -> None: + """Native and sweep routes share one bounded OIDC exchange implementation.""" + + text = WORKFLOW.read_text(encoding="utf-8") + exchange = ROOT / "scripts" / "ci" / "exchange_opencode_app_token.sh" + shell = exchange.read_text(encoding="utf-8") + + assert text.count("bash scripts/ci/exchange_opencode_app_token.sh") == 2 + assert text.count("exchange_github_app_token") == 0 + assert shell.count("exchange_github_app_token") == 1 + assert "--connect-timeout 10 --max-time 30" in shell + assert "OPENCODE_APP_TOKEN=" in shell + + def test_quality_workflow_measures_exact_files_without_module_name_warnings() -> None: """Coverage includes the two script paths instead of treating paths as modules."""