From aaa15089fae9f3385b1c4c13cdb8bdd48240e40c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 08:07:00 +0000 Subject: [PATCH 1/2] feat(scheduler): hourly org-wide approved-PR queue sweep Event-driven scheduler runs in target repositories stop retrying once their triggering event is consumed, so a PR that becomes mergeable AFTER its last event (approval published after the scheduler pass, merge-preview checks landing late, a temporary base-branch policy blocker clearing) has no later trigger and accumulates as approved-but-unmerged. Live evidence: bandscope #600/#604/#606/#627, html4tree #139-#148, clearfolio #136-#141, codec-carver #226-#232, appguardrail #278-#283, keyverse #10/#14, gyeot #9/#10, aFIPC #127/#128, nonnest2 #42/#44/#45, naruon #1034. Add an org-queue-sweep job to the central scheduler workflow: - runs hourly (cron 17 * * * *) only in ContextualWisdomLab/.github, or on workflow_dispatch with org_sweep=true; the single-repository scan skips those triggers so nothing double-runs - re-runs the trusted scheduler script against every non-archived org repository through the same guarded merge/update/review contract - requires a cross-repository mutation credential (PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the exchanged OpenCode app token) and fails with a visible ::error reason instead of silently no-opping on the repository-scoped github.token - prints each repository's per-PR decision log so every unmerged PR has a concrete logged reason at most one hour old - queue hygiene: cancels workflow runs still queued after ORG_SWEEP_STALE_QUEUE_HOURS (default 24h), logging run id, workflow, head branch, and age, so the Actions queue only holds current-head work Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018Fxd76REwJfmQcXCJjLi6Z --- .../workflows/pr-review-merge-scheduler.yml | 296 ++++++++++++++++++ docs/org-required-workflow-rollout.md | 3 + .../test_required_workflow_queue_contract.py | 34 ++ 3 files changed, 333 insertions(+) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index e074c639..b2582fa0 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -67,6 +67,13 @@ on: type: string schedule: - cron: "*/30 * * * *" + # Hourly org-wide sweep cadence for the org-queue-sweep job below. Target + # repositories only receive scheduler runs on PR events, review/security + # workflow completion, and protected-branch pushes; a PR whose approval or + # required checks land AFTER its last event has no later trigger and sits + # approved-but-unmerged until a human pushes something. The sweep closes + # that gap on a fixed heartbeat. + - cron: "17 * * * *" workflow_dispatch: inputs: dry_run: @@ -74,6 +81,11 @@ on: required: false default: false type: boolean + org_sweep: + description: Run the organization-wide queue sweep instead of the single-repository scan + required: false + default: false + type: boolean max_prs: description: Maximum open PRs to inspect required: false @@ -139,6 +151,8 @@ jobs: scan-pr-queue: # workflow_dispatch review runs do not reliably carry pull_requests metadata. # Without this guard, one completed central review can wake a repo-wide scan. + # The org-sweep cron and org_sweep dispatches are handled by org-queue-sweep + # below; skipping them here avoids a duplicate same-repository scan. if: >- ( github.event_name != 'pull_request_target' || @@ -150,6 +164,14 @@ jobs: github.event.workflow_run.conclusion != 'cancelled' && github.event.workflow_run.pull_requests[0].number ) + ) && + ( + github.event_name != 'schedule' || + github.event.schedule != '17 * * * *' + ) && + ( + github.event_name != 'workflow_dispatch' || + inputs.org_sweep != true ) runs-on: ubuntu-latest permissions: @@ -364,3 +386,277 @@ jobs: args+=(--no-update-branches) fi python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" + + org-queue-sweep: + # Organization-wide approved-PR fallback sweep. Event-driven scheduler runs + # in target repositories stop retrying once their triggering event is + # consumed, so a PR that becomes mergeable AFTER its last event (approval + # published after the scheduler pass, required merge-preview checks landing + # late, a base-branch policy blocker clearing) stays approved-but-unmerged + # with no later trigger. This job re-runs the same trusted scheduler against + # every organization repository on an hourly heartbeat so each such PR is + # merged, branch-updated, or leaves a concrete per-PR blocker reason in this + # log. It never bypasses policy: all mutations go through the same guarded + # scheduler contract as the per-repository runs. + if: >- + github.repository == 'ContextualWisdomLab/.github' && + ( + (github.event_name == 'schedule' && github.event.schedule == '17 * * * *') || + (github.event_name == 'workflow_dispatch' && inputs.org_sweep == true) + ) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: write + checks: read + contents: write + id-token: write + pull-requests: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GH_TOKEN: ${{ github.token }} + DRY_RUN: ${{ inputs.dry_run == true }} + ORG_SWEEP_OWNER: ContextualWisdomLab + ORG_SWEEP_MAX_PRS: ${{ vars.ORG_SWEEP_MAX_PRS || '30' }} + ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1' }} + ORG_SWEEP_STALE_QUEUE_HOURS: ${{ vars.ORG_SWEEP_STALE_QUEUE_HOURS || '24' }} + STALE_OPENCODE_MINUTES: ${{ vars.STALE_OPENCODE_MINUTES || '420' }} + steps: + - name: Exchange OpenCode app token for sweep mutations + id: sweep_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + 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 \ + -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 \ + -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" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Resolve trusted scheduler source ref + id: trusted_source + env: + JOB_CONTEXT_JSON: ${{ toJSON(job) }} + GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} + run: | + set -euo pipefail + python3 <<'PY' >>"$GITHUB_OUTPUT" + import json + import os + import re + import sys + + try: + job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") + github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") + except json.JSONDecodeError as exc: + print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) + raise SystemExit(1) + + trusted_repository = str( + job_context.get("workflow_repository") or "ContextualWisdomLab/.github" + ).strip() + trusted_ref = str( + job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" + ).strip() + workflow_ref = str( + job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" + ).strip() + + if not trusted_ref: + trusted_ref = "main" + prefix = "ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@" + if workflow_ref.startswith(prefix): + trusted_ref = workflow_ref.split("@", 1)[1] + + if trusted_repository != "ContextualWisdomLab/.github": + print("::error::Trusted scheduler workflow repository resolved outside ContextualWisdomLab/.github.", file=sys.stderr) + raise SystemExit(1) + if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): + print("::error::Trusted scheduler workflow ref resolved to an invalid value.", file=sys.stderr) + raise SystemExit(1) + + print(f"repository={trusted_repository}") + print(f"ref={trusted_ref}") + PY + + - name: Materialize trusted scheduler + env: + GH_TOKEN: ${{ github.token }} + TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }} + run: | + set -euo pipefail + if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Trusted scheduler source ref must resolve to the immutable workflow commit SHA before archive materialization." + exit 1 + fi + trusted_archive="${RUNNER_TEMP}/trusted-scheduler-source.tar.gz" + api_url="${GITHUB_API_URL:-https://api.github.com}" + curl -fsSL \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -o "$trusted_archive" \ + "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" + tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 + test -f scripts/ci/pr_review_merge_scheduler.py + + - name: Self-test scheduler + run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test + + - name: Sweep organization repository queues + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }} + SCHEDULER_ACTIONS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_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.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github + SCHEDULER_REQUIRED_WORKFLOW_REF: main + SCHEDULER_ALLOW_CROSS_REPO_WORKFLOW_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} + run: | + set -euo pipefail + if [ "$SCHEDULER_MUTATION_TOKEN_SOURCE" = "github-token" ]; then + # github.token is repository-scoped to .github and cannot mutate + # sibling repositories; a sweep with it would silently do nothing. + echo "::error::Organization queue sweep has no cross-repository mutation credential. Configure the PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN secret (or keep the OpenCode app token exchange available) so approved PRs in target repositories can be merged or updated." + exit 1 + fi + echo "Sweep mutation token source: $SCHEDULER_MUTATION_TOKEN_SOURCE" + + repositories_json="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "/orgs/${ORG_SWEEP_OWNER}/repos?per_page=100&type=all" --paginate + )" + mapfile -t sweep_targets < <( + jq -r ' + .[] + | select(.archived == false and .disabled == false) + | select(.full_name != "ContextualWisdomLab/.github") + | "\(.full_name)\t\(.default_branch)" + ' <<<"$repositories_json" + ) + echo "Sweeping ${#sweep_targets[@]} repositories." + + failures=0 + for target in "${sweep_targets[@]}"; do + repo_full_name="${target%%$'\t'*}" + default_branch="${target##*$'\t'}" + echo "::group::Sweep ${repo_full_name} (base ${default_branch})" + + open_pr_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${repo_full_name}/pulls?state=open&per_page=1&base=${default_branch}" \ + --jq 'length' || echo "unknown" + )" + if [ "$open_pr_count" = "0" ]; then + echo "No open PRs targeting ${default_branch}; skipping." + echo "::endgroup::" + continue + fi + + args=( + --repo "$repo_full_name" + --base-branch "$default_branch" + --max-prs "$ORG_SWEEP_MAX_PRS" + --review-workflow "Required OpenCode Review" + --review-dispatch-limit "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" + --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" + --trigger-reviews + --enable-auto-merge + --merge-mode direct_or_auto + --update-branches + ) + if [ "$DRY_RUN" = "true" ]; then + args+=(--dry-run) + fi + if ! python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}"; then + echo "::error::Queue sweep failed for ${repo_full_name}; see the decision log above for the concrete per-PR reason." + failures=$((failures + 1)) + fi + + # Queue hygiene: a run still queued after ORG_SWEEP_STALE_QUEUE_HOURS + # belongs to a head that events will never revisit (closed PR, force- + # pushed branch, or runner starvation from a previous outage). Cancel + # it so the Actions queue only holds current-head work. + stale_cutoff="$(date -u -d "${ORG_SWEEP_STALE_QUEUE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)" + stale_runs_json="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${repo_full_name}/actions/runs?status=queued&per_page=100" \ + --jq "[.workflow_runs[] | select(.created_at < \"${stale_cutoff}\") | {id, name, head_branch, created_at}]" + )" || stale_runs_json="[]" + stale_count="$(jq 'length' <<<"$stale_runs_json")" + if [ "$stale_count" -gt 0 ]; then + echo "Cancelling ${stale_count} queued run(s) older than ${ORG_SWEEP_STALE_QUEUE_HOURS}h:" + jq -r '.[] | " run \(.id) [\(.name)] on \(.head_branch) queued since \(.created_at)"' <<<"$stale_runs_json" + if [ "$DRY_RUN" != "true" ]; then + while IFS= read -r run_id; do + if ! gh api -X POST "/repos/${repo_full_name}/actions/runs/${run_id}/cancel" >/dev/null; then + echo "Could not cancel run ${run_id} in ${repo_full_name}; it may have started or finished already." + fi + done < <(jq -r '.[].id' <<<"$stale_runs_json") + fi + fi + echo "::endgroup::" + done + + if [ "$failures" -gt 0 ]; then + echo "::error::Organization queue sweep completed with ${failures} repository failure(s); each failure's reason is printed in its repository group above." + exit 1 + fi + echo "Organization queue sweep completed cleanly." diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 985e9048..501b95d2 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -104,6 +104,9 @@ The central `.github/workflows/pr-review-merge-scheduler.yml` is now part of the Do not centralize the scheduler by running a `.github` scheduled job against other repositories with the `.github` repository token. That would either fail permission checks or use the wrong mutation actor. The central path is a required workflow executed in each target repository context. +- Heartbeat fallback posture: event-driven target-repository runs stop retrying once their triggering event is consumed, so a PR that becomes mergeable AFTER its last event (approval published after the scheduler pass, merge-preview checks landing late, a temporary base-branch policy blocker clearing) has no later trigger and sits approved-but-unmerged. The `org-queue-sweep` job in the central scheduler workflow closes this gap: it runs hourly (`17 * * * *`) only in `ContextualWisdomLab/.github`, re-runs the same trusted scheduler script against every non-archived organization repository, and merges/updates through the identical guarded contract. It never uses the `.github` repository `github.token` for sibling mutations — it requires `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or the exchanged OpenCode app token, and fails with a visible `::error` reason when no cross-repository mutation credential is available instead of silently no-opping. Every swept repository prints its per-PR decision log, so an unmerged PR always has a concrete logged reason at most one hour old. +- Queue hygiene posture: during the sweep, workflow runs still `queued` after `ORG_SWEEP_STALE_QUEUE_HOURS` (default 24h) are cancelled with their run id, workflow name, head branch, and age logged. A run queued that long belongs to a head that PR events will never revisit (closed PR, force-pushed branch, or a previous runner outage), and leaving it keeps the Actions queue holding non-current-head work. + ## Scope The active ruleset no longer maintains a repository-name allowlist. Live diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 14a080b2..01c3ddbe 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -202,6 +202,40 @@ def test_unassociated_review_workflow_runs_do_not_scan_the_whole_pr_queue() -> N assert "github.event.workflow_run.pull_requests[0].number" in workflow +def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: + """Guard the org-wide approved-PR fallback sweep contract. + + Target repositories only receive scheduler runs on PR events, so a PR that + becomes mergeable after its last event sits approved-but-unmerged forever. + The sweep job must exist, run only from the central repository on its own + cron, use a cross-repository mutation credential (never the repository + github.token silently), skip the central repository itself, and fail with a + visible reason when it cannot mutate sibling repositories. + """ + workflow = workflow_text("pr-review-merge-scheduler.yml") + + assert "org-queue-sweep:" in workflow + assert "- cron: \"17 * * * *\"" in workflow + assert "github.repository == 'ContextualWisdomLab/.github'" in workflow + assert "github.event.schedule == '17 * * * *'" in workflow + assert "inputs.org_sweep == true" in workflow + # The single-repository scan must not double-run on the sweep cron. + assert "github.event.schedule != '17 * * * *'" in workflow + assert "inputs.org_sweep != true" in workflow + # The sweep must never silently no-op with the repository-scoped token. + assert ( + "Organization queue sweep has no cross-repository mutation credential." + in workflow + ) + assert 'select(.full_name != "ContextualWisdomLab/.github")' in workflow + assert "select(.archived == false and .disabled == false)" in workflow + # Every repository failure must leave a concrete logged reason. + assert "see the decision log above for the concrete per-PR reason" in workflow + # Queue hygiene: stale queued runs are cancelled with a logged identity. + assert "ORG_SWEEP_STALE_QUEUE_HOURS" in workflow + assert "/actions/runs?status=queued&per_page=100" in workflow + + def test_fix_scheduler_cancels_superseded_cron_runs() -> None: workflow = workflow_text("pr-review-fix-scheduler.yml") From b7c7b9a29e3cae80718c7eb52b20db04cc39554b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 09:32:16 +0000 Subject: [PATCH 2/2] feat(noema-review): NOEMA_REVIEW_TOKEN PAT fallback for the second reviewer The two-reviewer merge rule needs a second approving-review identity beyond OpenCode. Today it only works if the Noema Worker is deployed and NOEMA_TOKEN_EXCHANGE_URL is set, so no PR gets a second review and .github's classic 2-review protection blocks every .github PR. Add a NOEMA_REVIEW_TOKEN secret fallback: when present it is used directly as the reviewer identity and the OIDC app-token exchange is skipped; the review step prefers it over the exchanged app token. The secret is never emitted as a step output. When neither the secret nor the exchange URL is configured, the step still emits the unconfigured notice and skips (green-by-skip), not a failure. noema_review_gate.py already refuses to review as a primary review actor, so the fallback cannot manufacture a fake second review from the github-actions/opencode identity. Pairs with the noema PydanticAI reviewer agent (ContextualWisdomLab/noema#9) that produces the verdict this identity publishes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018Fxd76REwJfmQcXCJjLi6Z --- .github/workflows/noema-review.yml | 17 ++++++++++-- docs/org-required-workflow-rollout.md | 24 +++++++++++++++++ .../test_required_workflow_queue_contract.py | 26 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 96cfcf78..1634ed09 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -142,6 +142,7 @@ jobs: env: OIDC_AUDIENCE: ${{ vars.NOEMA_OIDC_AUDIENCE || 'cwl-noema-review' }} TOKEN_EXCHANGE_URL: ${{ vars.NOEMA_TOKEN_EXCHANGE_URL || vars.NOEMA_EXCHANGE_URL || '' }} + NOEMA_REVIEW_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN }} run: | set -euo pipefail @@ -159,6 +160,18 @@ jobs: exit 0 } + # PAT fallback: when a NOEMA_REVIEW_TOKEN secret is present, use it as + # the reviewer identity directly and skip the OIDC app-token exchange. + # This lets the independent second reviewer satisfy the two-reviewer + # merge rule without deploying the Noema Worker. The secret value is + # never emitted as a step output; the review step reads it from secrets. + if [ -n "${NOEMA_REVIEW_TOKEN:-}" ]; then + echo "available=true" >>"$GITHUB_OUTPUT" + echo "source=pat" >>"$GITHUB_OUTPUT" + echo "::notice::Noema reviewer using the NOEMA_REVIEW_TOKEN secret fallback identity." + exit 0 + fi + if [ -z "${TOKEN_EXCHANGE_URL:-}" ]; then mark_unconfigured "Noema app token exchange unconfigured: NOEMA_TOKEN_EXCHANGE_URL or NOEMA_EXCHANGE_URL is not configured; Noema review skipped until the exchange service is deployed." fi @@ -212,9 +225,9 @@ jobs: - name: Run Noema LLM review and submit verdict if: env.PR_NUMBER != '' env: - GH_TOKEN: ${{ steps.noema_app_token.outputs.token }} + GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_app_token.outputs.token }} NOEMA_APP_TOKEN_AVAILABLE: ${{ steps.noema_app_token.outputs.available }} - NOEMA_REVIEW_TOKEN_SOURCE: noema-review-app-oidc + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_app_token.outputs.source == 'pat' && 'noema-review-pat' || 'noema-review-app-oidc' }} NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL || '' }} NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL || '' }} NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY || '' }} diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 501b95d2..51e8c8ae 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -107,6 +107,30 @@ Do not centralize the scheduler by running a `.github` scheduled job against oth - Heartbeat fallback posture: event-driven target-repository runs stop retrying once their triggering event is consumed, so a PR that becomes mergeable AFTER its last event (approval published after the scheduler pass, merge-preview checks landing late, a temporary base-branch policy blocker clearing) has no later trigger and sits approved-but-unmerged. The `org-queue-sweep` job in the central scheduler workflow closes this gap: it runs hourly (`17 * * * *`) only in `ContextualWisdomLab/.github`, re-runs the same trusted scheduler script against every non-archived organization repository, and merges/updates through the identical guarded contract. It never uses the `.github` repository `github.token` for sibling mutations — it requires `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or the exchanged OpenCode app token, and fails with a visible `::error` reason when no cross-repository mutation credential is available instead of silently no-opping. Every swept repository prints its per-PR decision log, so an unmerged PR always has a concrete logged reason at most one hour old. - Queue hygiene posture: during the sweep, workflow runs still `queued` after `ORG_SWEEP_STALE_QUEUE_HOURS` (default 24h) are cancelled with their run id, workflow name, head branch, and age logged. A run queued that long belongs to a head that PR events will never revisit (closed PR, force-pushed branch, or a previous runner outage), and leaving it keeps the Actions queue holding non-current-head work. +## Second-reviewer (Noema) posture + +The org's two-reviewer merge rule needs a second approving-review identity +independent of OpenCode. That identity is the Noema reviewer, whose judgement +plane is the PydanticAI `ReviewAgent` product in `ContextualWisdomLab/noema` +(`reviewer/noema_reviewer`, noema#9) and whose GitHub identity comes from the +Noema GitHub App (token-exchange Worker) *or* a `NOEMA_REVIEW_TOKEN` secret. + +- Token posture: `noema-review.yml` now prefers a `NOEMA_REVIEW_TOKEN` secret + as the reviewer identity when present, skipping the OIDC app-token exchange. + This lets the second reviewer submit real approving reviews without deploying + the Noema Worker. When neither the secret nor `NOEMA_TOKEN_EXCHANGE_URL` is + configured, the step still emits the unconfigured notice and skips rather than + failing the check. +- Honesty posture: `noema_review_gate.py` refuses to review as a primary review + actor (`opencode-agent`, `github-actions`), so a `NOEMA_REVIEW_TOKEN` that + resolves to one of those identities cannot manufacture a fake second review — + it must be a distinct write-access identity. +- Minimal admin config to activate the second reviewer: set the org/repo + secrets `NOEMA_REVIEW_TOKEN` (a distinct write-access token) and the LLM + endpoint (`NOEMA_LLM_MODEL`, `NOEMA_LLM_API_URL`, `NOEMA_LLM_API_KEY`). Until + then the `noema-review` check stays green-by-skip and only OpenCode approves, + so the classic `.github` 2-review protection keeps `.github` PRs blocked. + ## Scope The active ruleset no longer maintains a repository-name allowlist. Live diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 01c3ddbe..b23f126b 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -178,6 +178,32 @@ def test_noema_workflow_run_without_pull_request_skips_before_token_exchange() - assert workflow.count("if: env.PR_NUMBER != ''") >= 3 +def test_noema_review_supports_review_token_pat_fallback() -> None: + """Guard the NOEMA_REVIEW_TOKEN PAT fallback that activates the second reviewer. + + The two-reviewer merge rule needs a second approving-review identity. Rather + than forcing a Worker deployment, a NOEMA_REVIEW_TOKEN secret must be usable + directly as the reviewer identity: when it is present the OIDC app-token + exchange is skipped, and the review step must prefer it. The secret value is + never emitted as a step output. + """ + workflow = workflow_text("noema-review.yml") + + assert "NOEMA_REVIEW_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN }}" in workflow + assert 'if [ -n "${NOEMA_REVIEW_TOKEN:-}" ]; then' in workflow + assert "Noema reviewer using the NOEMA_REVIEW_TOKEN secret fallback identity." in workflow + # The review step must prefer the PAT over the exchanged app token. + assert ( + "GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_app_token.outputs.token }}" + in workflow + ) + # The unconfigured-exchange notice stays for the no-PAT, no-exchange-URL case. + assert ( + "Noema app token exchange unconfigured: NOEMA_TOKEN_EXCHANGE_URL or " + "NOEMA_EXCHANGE_URL is not configured" in workflow + ) + + def test_noema_and_scheduler_trusted_checkouts_use_static_main() -> None: noema = workflow_text("noema-review.yml") scheduler = workflow_text("pr-review-merge-scheduler.yml")