From 822752b04a8b90aa5fc34b858be4f776a60910f2 Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Tue, 13 Oct 2026 07:56:22 -0400 Subject: [PATCH 1/2] ci(OMN-20708): add claim_source input and work-ledger check reader The reusable workflow gains a claim_source input with values file (default) and work-ledger. With work-ledger, the job reads the check "branch-claim / work-ledger" posted on the pull request head by the GitHub App onexbot-occ-writer (omnimarket's node_branch_claim_check_effect, omnimarket#3547). It opens no database connection and checks out no ledger. The job waits up to 900 seconds (input lab_check_wait_seconds). This change is the earlier draft omniclaude#2608 rebased onto dev. The folded branch-claim-gate.yml no longer exists, so its wiring is now in ci.yml. The caller branch-claim-check.yml names claim_source: file explicitly and gains checks: read. It stays on file until a completed lab check is visible on an omniclaude pull request head; none was visible on omniclaude#2708, #2710, #2711 or #2672 on 2026-10-13. tests/scripts/test_branch_claim_check_workflow.py runs the reader's own Bash with a fake gh and real jq. It no longer skips when jq is missing; it fails. New named cases: ledger_absent_passes, work_ledger_source_runs_no_ledger_step, claimed_elsewhere_refused, branch_claim_check_workflow_is_wired. The test is a named step "Branch-claim check workflow reader (OMN-20708)" of the Branch Claim Gate job in ci.yml, and a check-only pre-commit hook branch-claim-check-workflow. The Branch Claim Gate path filter now names the caller, the test and .pre-commit-config.yaml. contracts/OMN-20708.yaml added with three acceptance criteria and their dod_evidence. 38 tests in the file pass. Not changed: scripts/branch_claim.py, the pre-push hook, required-checks.yaml, branch protection. --- .../workflows/branch-claim-check-reusable.yml | 149 +++++- .github/workflows/branch-claim-check.yml | 12 + .github/workflows/ci.yml | 10 + .pre-commit-config.yaml | 10 + contracts/OMN-20708.yaml | 57 +++ .../test_branch_claim_check_workflow.py | 436 ++++++++++++++++++ 6 files changed, 663 insertions(+), 11 deletions(-) create mode 100644 contracts/OMN-20708.yaml create mode 100644 tests/scripts/test_branch_claim_check_workflow.py diff --git a/.github/workflows/branch-claim-check-reusable.yml b/.github/workflows/branch-claim-check-reusable.yml index 4f7934a1e0..616b407fd8 100644 --- a/.github/workflows/branch-claim-check-reusable.yml +++ b/.github/workflows/branch-claim-check-reusable.yml @@ -24,14 +24,19 @@ # half so arming it later is a one-word change in the caller, not a rewrite -- # but nothing should pass `refuse` until that measurement exists. # -# ONE RESOLUTION, NOT TWO. Everything this job decides is decided by -# `scripts/branch_claim.py` in this repository, which is the same module the -# pre-push hook runs. A second implementation of the comparison is a second -# place for it to drift, and a local refusal that disagrees with a remote check -# is invisible until they disagree about a real push. +# TWO SOURCES. `work-ledger` reads the canonical lab node's GitHub check for the +# PR head. The database work ledger is read by the lab node; this job opens no +# database connection and only interprets the node's reported outcome. +# `file` runs `scripts/branch_claim.py`, the same module the pre-push hook runs, +# against the private ledger and its rolls. That path is retained only until +# the follow-up removal. This job implements no claim comparison of its own on +# either path. The lab node's resolution is a port of `scripts/claim_index.py` +# and `scripts/branch_claim.py` over the database rows, so a change to the +# resolution here has to land in the node as well until the pre-push hook asks +# the same node. # -# `secrets: inherit` IS REQUIRED. The claim store lives in a PRIVATE workspace -# repository, and this job mints an installation token to read it. The claim +# `secrets: inherit` IS REQUIRED FOR `file`. The claim store lives in a PRIVATE +# workspace repository, and this job mints an installation token to read it. The claim # index module that resolves it does NOT: it is vendored in this repository as # `scripts/claim_index.py` beside the resolver (OMN-19722), and is fetched at the # same pin. Fetching it unpinned from the store's default branch let a wording @@ -47,7 +52,7 @@ # uses: OmniNode-ai/omniclaude/.github/workflows/branch-claim-check-reusable.yml@ # secrets: inherit # -# WHAT THIS CHECK CANNOT SEE, measured on its own first run. The store is read +# WHAT `file` CANNOT SEE, measured on its own first run. The store is read # from the private repository's DEFAULT BRANCH, so a claim row that exists only in # a lane's working copy is invisible here until it is committed and merged. The # first run of this workflow resolved its own branch as `unclaimed` for exactly @@ -64,6 +69,26 @@ name: Branch Claim Check (reusable) on: workflow_call: inputs: + claim_source: + description: "'file' reads the private ledger; 'work-ledger' reads the lab node's check." + type: string + required: false + default: file + lab_check_name: + description: Name of the canonical lab branch-claim check on the PR head. + type: string + required: false + default: branch-claim / work-ledger + lab_check_app_slug: + description: GitHub App slug trusted to publish the lab check. + type: string + required: false + default: onexbot-occ-writer + lab_check_wait_seconds: + description: Maximum seconds to wait for a completed lab check. + type: number + required: false + default: 900 mode: description: >- 'record' (default) reports findings and exits 0. 'refuse' fails the @@ -116,6 +141,7 @@ on: permissions: contents: read + checks: read jobs: branch-claim-check: @@ -140,13 +166,108 @@ jobs: && fromJSON(vars.OMNI_PUBLIC_PR_RUNS_ON_JSON || '["ubuntu-latest"]') || fromJSON(vars.OMNI_TRUSTED_CI_RUNS_ON_JSON || '["self-hosted","omnibase-ci"]') }} - timeout-minutes: 15 + # Leave room for the default 900-second lab wait and its final diagnostic. + timeout-minutes: 20 steps: + - name: Validate the claim source + env: + CLAIM_SOURCE: ${{ inputs.claim_source }} + run: | + set -euo pipefail + case "$CLAIM_SOURCE" in + file|work-ledger) ;; + *) + echo "::error::Unknown claim_source '$CLAIM_SOURCE'. THE CHECK DID NOT RUN." + exit 1 + ;; + esac + + - name: Read the lab branch-claim check for this head + if: inputs.claim_source == 'work-ledger' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + LAB_CHECK_NAME: ${{ inputs.lab_check_name }} + LAB_CHECK_APP_SLUG: ${{ inputs.lab_check_app_slug }} + LAB_CHECK_WAIT_SECONDS: ${{ inputs.lab_check_wait_seconds }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + encoded_name="$(jq -rn --arg name "$LAB_CHECK_NAME" '$name | @uri')" + endpoint="repos/$REPO/commits/$HEAD_SHA/check-runs?check_name=$encoded_name&filter=all&per_page=100" + wait_seconds="$(jq -en --argjson wait "$LAB_CHECK_WAIT_SECONDS" '$wait | select(. >= 0) | ceil')" + deadline=$((SECONDS + wait_seconds)) + selected="" + # Always attempt once, including when the configured wait is zero. + while :; do + if response="$(gh api --paginate "$endpoint")"; then + ignored="$(jq -s --arg app "$LAB_CHECK_APP_SLUG" \ + '[.[].check_runs[] | select(.app.slug != $app)] | length' <<< "$response")" + echo "Ignored $ignored check run(s) from other apps; trusted app: $LAB_CHECK_APP_SLUG." + selected="$(jq -sc --arg app "$LAB_CHECK_APP_SLUG" \ + '[.[].check_runs[] | select(.app.slug == $app and .status == "completed")] | sort_by(.completed_at) | last // empty' \ + <<< "$response")" + if [[ -n "$selected" ]]; then + break + fi + else + echo "Lab check API request failed; retrying within the $LAB_CHECK_WAIT_SECONDS second deadline." + fi + remaining=$((deadline - SECONDS)) + if ((remaining <= 0)); then + break + fi + sleep_for=20 + if ((remaining < sleep_for)); then + sleep_for=$remaining + fi + sleep "$sleep_for" + if ((SECONDS >= deadline)); then + break + fi + done + if [[ -z "$selected" ]]; then + echo "Lab check source: $LAB_CHECK_NAME; app=$LAB_CHECK_APP_SLUG; head=$HEAD_SHA; no completed outcome line or html_url available." + echo "::error::Check '$LAB_CHECK_NAME' from app '$LAB_CHECK_APP_SLUG' did not complete for head $HEAD_SHA within $LAB_CHECK_WAIT_SECONDS seconds. The lab node did not answer for this head. THE CHECK DID NOT RUN." + exit 1 + fi + + summary="$(jq -r '.output.summary // ""' <<< "$selected")" + html_url="$(jq -r '.html_url // ""' <<< "$selected")" + first_line="${summary%%$'\n'*}" + printf '%s\n%s\n' "$html_url" "$first_line" + if [[ ! "$first_line" =~ ^branch-claim-outcome:\ (held-elsewhere|fence-behind|unidentified|held-by-pusher|unclaimed|no-ticket|did-not-run)\ ]]; then + echo "::error::Check '$LAB_CHECK_NAME' from app '$LAB_CHECK_APP_SLUG' has an invalid outcome line for head $HEAD_SHA. THE CHECK DID NOT RUN." + exit 1 + fi + outcome="${BASH_REMATCH[1]}" + case "$outcome" in + did-not-run) + printf '%s\n' "$summary" + cause="${summary#*$'\n'}" + cause="${cause%%$'\n'*}" + echo "::error::$cause THE CHECK DID NOT RUN." + exit 1 + ;; + held-elsewhere|fence-behind|unidentified) + while IFS= read -r line; do + printf '::warning::%s\n' "$line" + done <<< "$summary" + if [[ "$outcome" != "unidentified" && "$MODE" == "refuse" ]]; then + exit 1 + fi + ;; + held-by-pusher|unclaimed|no-ticket) ;; + esac + # The HEAD commits, not the merge commit. The resolution reads the lane # trailers of the commits the branch actually carries; a merge commit git # synthesised carries none, and reading it would report every pull request # as unidentified. - name: Checkout the pull request head + if: inputs.claim_source == 'file' uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} @@ -158,6 +279,7 @@ jobs: persist-credentials: false - name: Set up Python 3.13 + if: inputs.claim_source == 'file' uses: actions/setup-python@v7 with: python-version: '3.13' @@ -167,6 +289,7 @@ jobs: # side path -- mirrors public-repo-hygiene-reusable.yml and # kb-doc-gate-reusable.yml. - name: Resolve the gate pin + if: inputs.claim_source == 'file' id: pin env: WORKFLOW_SHA: ${{ job.workflow_sha }} @@ -187,7 +310,7 @@ jobs: echo "branch-claim pin: $resolved_pin" - name: Fetch the resolution (omniclaude canonical source) - if: github.repository != 'OmniNode-ai/omniclaude' + if: inputs.claim_source == 'file' && github.repository != 'OmniNode-ai/omniclaude' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: OmniNode-ai/omniclaude @@ -201,7 +324,7 @@ jobs: sparse-checkout-cone-mode: false - name: Assert the branch-claim checkout is at the pin - if: github.repository != 'OmniNode-ai/omniclaude' + if: inputs.claim_source == 'file' && github.repository != 'OmniNode-ai/omniclaude' env: GATE_PIN: ${{ steps.pin.outputs.sha }} run: | @@ -221,6 +344,7 @@ jobs: # request `owner/`, which fails in a way that reads like a transient # network problem rather than a missing configuration. Name the cause. - name: Resolve the private claim store repo + if: inputs.claim_source == 'file' id: store-repo env: EXPLICIT: ${{ inputs.store_repo_name }} @@ -240,6 +364,7 @@ jobs: echo "name=${resolved}" >> "$GITHUB_OUTPUT" - name: Mint token for the private claim store repo + if: inputs.claim_source == 'file' id: store-token uses: actions/create-github-app-token@v3 with: @@ -259,6 +384,7 @@ jobs: # no code is fetched from it (the index module is vendored here, at the # pin, since OMN-19722). An unreadable store is a refusal. - name: Fetch the claim store and its rolls + if: inputs.claim_source == 'file' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository_owner }}/${{ steps.store-repo.outputs.name }} @@ -271,6 +397,7 @@ jobs: sparse-checkout-cone-mode: false - name: Resolve this branch against the claim store + if: inputs.claim_source == 'file' env: MODE: ${{ inputs.mode }} BRANCH: ${{ github.head_ref }} diff --git a/.github/workflows/branch-claim-check.yml b/.github/workflows/branch-claim-check.yml index 8053b9b32f..ab5d604895 100644 --- a/.github/workflows/branch-claim-check.yml +++ b/.github/workflows/branch-claim-check.yml @@ -7,6 +7,15 @@ # local path rather than a pinned cross-repository SHA. Caller repositories pin # an immutable omniclaude SHA instead; see the reusable workflow's header. # +# OMN-20708: the reusable workflow can read the lab node's branch-claim check +# (`claim_source: work-ledger`, which opens no ledger) instead of the private +# markdown ledger. This caller stays on `file` until a completed +# `branch-claim / work-ledger` check from the writer App is visible on an +# omniclaude pull request head: a caller flipped before then waits out the whole +# window on every pull request and fails with THE CHECK DID NOT RUN. Observed +# 2026-10-13: none of omniclaude#2708, #2710, #2711 or #2672 carried that check. +# The flip is the one `claim_source` line below. +# # It RECORDS. The operator ruling of 2026-09-13T09:11:33Z put the refusing half # behind a measurement of the release path, so `mode` stays at its default here. @@ -24,8 +33,11 @@ concurrency: permissions: contents: read + checks: read jobs: branch-claim-check: uses: ./.github/workflows/branch-claim-check-reusable.yml + with: + claim_source: file secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b20999e1..85ad1bcc7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4578,10 +4578,13 @@ jobs: - 'scripts/hooks/pre-push-branch-claim' - 'tests/scripts/test_branch_claim.py' - 'tests/scripts/test_branch_claim_hook.py' + - 'tests/scripts/test_branch_claim_check_workflow.py' - 'tests/scripts/test_lane_identity_canary.py' - 'scripts/hooks/prepare-commit-msg-lane' - '.github/workflows/ci.yml' + - '.github/workflows/branch-claim-check.yml' - '.github/workflows/branch-claim-check-reusable.yml' + - '.pre-commit-config.yaml' push: ['main', 'dev', 'hotfix/**'] check-deterministic-skills: paths: @@ -4885,6 +4888,13 @@ jobs: tests/scripts/test_branch_claim_hook.py \ tests/scripts/test_lane_identity_canary.py -q + # OMN-20708: the reader of the lab node's branch-claim check (`claim_source: + # work-ledger`) runs its own Bash against a fake gh and real jq. Paired with + # the branch-claim-check-workflow pre-commit hook. This job's result is part + # of the required CI Summary context. + - name: Branch-claim check workflow reader (OMN-20708) + run: uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -p no:randomly + # Folded from .github/workflows/check-deterministic-skills.yml (OMN-20074); runs when its original `paths:` change. # Deterministic-Skill Routing Enforcement Gate (OMN-8749, wired under OMN-14808) # diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5af39b5eaf..82400f39cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -791,6 +791,16 @@ repos: always_run: true stages: [pre-commit] + # OMN-20708: the branch-claim check's lab-node reader. SYNC with the ci.yml + # Branch Claim Gate step "Branch-claim check workflow reader (OMN-20708)". + - id: branch-claim-check-workflow + name: Branch-claim check reads the lab node's check (OMN-20708) + entry: bash scripts/uv-run-worktree-safe.sh pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -p no:randomly + language: system + pass_filenames: false + files: '^(\.github/workflows/branch-claim-check(-reusable)?\.yml|tests/scripts/test_branch_claim_check_workflow\.py|\.pre-commit-config\.yaml|\.github/workflows/ci\.yml)$' + stages: [pre-commit] + # OMN-20910: a review voter's model is declared only in the contract # overlay. SYNC with the ci.yml refusal-surface step "Review voter model # comes from the overlay (OMN-20910)". diff --git a/contracts/OMN-20708.yaml b/contracts/OMN-20708.yaml new file mode 100644 index 0000000000..477d830e12 --- /dev/null +++ b/contracts/OMN-20708.yaml @@ -0,0 +1,57 @@ +--- +schema_version: "1.0.0" +ticket_id: "OMN-20708" +title: "The branch claim check can answer from the database work ledger, with the markdown ledger absent" +summary: "omniclaude's reusable branch-claim workflow gains a claim_source input. With work-ledger it reads the check the lab node posts on the pull request head (branch-claim / work-ledger, from the writer App), opens no database connection and checks out no ledger; with file (the default, and what this repository's own caller still passes) it runs the existing markdown-ledger resolution. The caller flips to work-ledger in a later change, once a completed lab check is visible on an omniclaude head. This slice adds the test that runs the reader's own Bash, a named step for it in the Branch Claim Gate job (a member of the required CI Summary umbrella) and a check-only pre-commit hook." +is_seam_ticket: false +interface_change: false +interfaces_touched: [] +requirements: + - id: "req-reader-needs-no-markdown-ledger" + statement: "With claim_source work-ledger the job runs no step that fetches or reads the markdown ledger, and an unheld branch passes." + acceptance: + - id: "AC1" + statement: "AC1: with work-ledger the only steps that run are the source validator and the lab-check reader, and the reader passes an unheld branch with no ledger present -- falsifier: uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k 'work_ledger_source_runs_no_ledger_step or ledger_absent_passes'" + - id: "req-reader-refuses-held-elsewhere" + statement: "A branch another lane holds is refused in refuse mode, and a missing, garbled or did-not-run lab answer never passes in either mode." + acceptance: + - id: "AC2" + statement: "AC2: held-elsewhere and fence-behind fail in refuse mode and only warn in record mode, and an absent, pending, foreign-app, garbled or did-not-run lab check fails in both modes -- falsifier: uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k 'claimed_elsewhere_refused or no_completed_matching_run_fails or garbled_first_line_fails or outcomes_follow_mode'" + - id: "req-reader-test-is-wired" + statement: "The reader's test file is a named step of a job the required CI Summary context covers and a check-only pre-commit hook." + acceptance: + - id: "AC3" + statement: "AC3: the test file is the step Branch-claim check workflow reader (OMN-20708) of the Branch Claim Gate job, its path filter names the caller, the reusable workflow and the test, and the branch-claim-check-workflow pre-commit hook runs it -- falsifier: uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k branch_claim_check_workflow_is_wired" +evidence_requirements: + - kind: "tests" + description: "The lab-check reader's Bash, run with a fake gh and real jq." + command: "uv run pytest tests/scripts/test_branch_claim_check_workflow.py tests/scripts/test_branch_claim.py -q --no-cov" +emergency_bypass: + enabled: false + justification: "" + follow_up_ticket_id: "" +dod_evidence: + - id: "dod-omn20708-ac1-no-markdown-ledger" + description: "With work-ledger the job runs only the validator and the reader, and an unheld branch passes with no ledger present." + source: "manual" + checks: + - check_type: "test_passes" + check_value: "uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k 'work_ledger_source_runs_no_ledger_step or ledger_absent_passes'" + cwd: "${OMNI_HOME}/omniclaude" + binds_ac: ["AC1"] + - id: "dod-omn20708-ac2-held-elsewhere-refused" + description: "A branch held elsewhere is refused in refuse mode and an unanswered lab check never passes." + source: "manual" + checks: + - check_type: "test_passes" + check_value: "uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k 'claimed_elsewhere_refused or no_completed_matching_run_fails or garbled_first_line_fails or outcomes_follow_mode'" + cwd: "${OMNI_HOME}/omniclaude" + binds_ac: ["AC2"] + - id: "dod-omn20708-ac3-wired" + description: "The test file is a named step of the Branch Claim Gate job (covered by the required CI Summary context) and a pre-commit hook entry." + source: "manual" + checks: + - check_type: "test_passes" + check_value: "uv run pytest tests/scripts/test_branch_claim_check_workflow.py -q --no-cov -k branch_claim_check_workflow_is_wired" + cwd: "${OMNI_HOME}/omniclaude" + binds_ac: ["AC3"] diff --git a/tests/scripts/test_branch_claim_check_workflow.py b/tests/scripts/test_branch_claim_check_workflow.py new file mode 100644 index 0000000000..06e62d7eb0 --- /dev/null +++ b/tests/scripts/test_branch_claim_check_workflow.py @@ -0,0 +1,436 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT + +"""Exercise the lab-check reader's actual Bash without contacting GitHub (OMN-20708). + +The branch claim check can answer from the lab node's check on the pull request head +(`claim_source: work-ledger`) instead of the private markdown ledger. This file is the +gate over that reader. Select by criterion: + +* ``-k ledger_absent_passes``: with no ledger on the runner, an unheld branch passes. +* ``-k claimed_elsewhere_refused``: a branch another lane holds, or a fence that is + behind, is refused in ``refuse`` mode and a missing, garbled or did-not-run answer + never passes in either mode. +* ``-k branch_claim_check_workflow_is_wired``: this file is a named step of the + ``Branch Claim Gate`` job, which the required CI Summary context covers, and a + check-only pre-commit hook. + +``unclaimed`` (the ticket has no claim row) stays clean on purpose, exactly as in +``test_branch_claim.py::test_unclaimed_is_clean``: the lab node reports it as a success. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +TEST_PATH = "tests/scripts/test_branch_claim_check_workflow.py" +WIRED_STEP_NAME = "Branch-claim check workflow reader (OMN-20708)" +HOOK_ID = "branch-claim-check-workflow" +CHECK_NAME = "branch-claim / work-ledger" +APP_SLUG = "onexbot-occ-writer" +HEAD_SHA = "a" * 40 +CHECK_URL = "https://github.com/example/repo/runs/123" +LAB_STEP_NAME = "Read the lab branch-claim check for this head" + + +def _workflow(name: str) -> dict[str, Any]: + return yaml.safe_load((WORKFLOWS / name).read_text()) + + +@pytest.fixture +def lab_step() -> dict[str, Any]: + workflow = _workflow("branch-claim-check-reusable.yml") + return next( + step + for step in workflow["jobs"]["branch-claim-check"]["steps"] + if step["name"] == LAB_STEP_NAME + ) + + +def _check( + outcome: str = "unclaimed", + *, + app: str = APP_SLUG, + completed_at: str = "2026-10-07T12:00:00Z", + status: str = "completed", +) -> dict[str, Any]: + return { + "app": {"slug": app}, + "status": status, + "completed_at": completed_at, + "html_url": CHECK_URL, + "output": { + "summary": ( + f"branch-claim-outcome: {outcome} ticket=OMN-20708 holder=peer-lane " + "window=2026-10-06T12:00:00Z..2026-10-07T12:00:00Z rows=3\n" + "The lab node's cause and findings: holder=peer-lane." + ), + }, + } + + +@pytest.fixture +def run_lab_step(tmp_path: Path, lab_step: dict[str, Any]): + # A skip would read as green on a runner that cannot execute the reader. + assert shutil.which("jq") is not None, "jq is required to run the reader's Bash" + + # Only gh is replaced: Bash, jq and the workflow script execute for real. + fake_gh = tmp_path / "gh" + fake_gh.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "calls = Path(os.environ['FAKE_GH_CALLS'])\n" + "with calls.open('a') as log:\n" + " log.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "error = os.environ.get('FAKE_GH_ERROR')\n" + "if error:\n" + " print(error, file=sys.stderr)\n" + " sys.exit(1)\n" + "print(os.environ['FAKE_GH_RESPONSE'])\n" + ) + fake_gh.chmod(0o755) + calls_path = tmp_path / "calls.jsonl" + + def run( + runs: list[dict[str, Any]], + mode: str = "record", + *, + api_error: str = "", + pages: list[dict[str, Any]] | None = None, + ) -> subprocess.CompletedProcess[str]: + calls_path.write_text("") + response_pages = pages if pages is not None else [{"check_runs": runs}] + env = { + **os.environ, + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "GH_TOKEN": "test-token", + "REPO": "example/repo", + "HEAD_SHA": HEAD_SHA, + "LAB_CHECK_NAME": CHECK_NAME, + "LAB_CHECK_APP_SLUG": APP_SLUG, + "LAB_CHECK_WAIT_SECONDS": "0", + "MODE": mode, + "FAKE_GH_CALLS": str(calls_path), + "FAKE_GH_RESPONSE": "\n".join(json.dumps(p) for p in response_pages), + "FAKE_GH_ERROR": api_error, + } + result = subprocess.run( + ["bash", "-c", lab_step["run"]], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + calls = [json.loads(line) for line in calls_path.read_text().splitlines()] + # A zero deadline permits exactly one request and never sleeps/retries. + assert calls == [ + [ + "api", + "--paginate", + f"repos/example/repo/commits/{HEAD_SHA}/check-runs" + "?check_name=branch-claim%20%2F%20work-ledger&filter=all&per_page=100", + ], + ] + return result + + return run + + +def test_sources_gate_all_file_steps(lab_step: dict[str, Any]) -> None: + assert lab_step["if"] == "inputs.claim_source == 'work-ledger'" + workflow = _workflow("branch-claim-check-reusable.yml") + steps = workflow["jobs"]["branch-claim-check"]["steps"] + file_steps = { + "Checkout the pull request head", + "Set up Python 3.13", + "Resolve the gate pin", + "Fetch the resolution (omniclaude canonical source)", + "Assert the branch-claim checkout is at the pin", + "Resolve the private claim store repo", + "Mint token for the private claim store repo", + "Fetch the claim store and its rolls", + "Resolve this branch against the claim store", + } + by_name = {step["name"]: step for step in steps} + for name in file_steps: + assert by_name[name]["if"].startswith("inputs.claim_source == 'file'") + for name in ( + "Fetch the resolution (omniclaude canonical source)", + "Assert the branch-claim checkout is at the pin", + ): + assert "&& github.repository != 'OmniNode-ai/omniclaude'" in by_name[name]["if"] + + +def test_lab_step_has_no_database_or_ledger_checkout(lab_step: dict[str, Any]) -> None: + script = lab_step["run"] + for forbidden in ( + "psql", + "asyncpg", + "psycopg", + "postgres", + "5436", + "DATABASE_URL", + "_DSN", + "checkout", + "ROLLING_WORK_LEDGER", + ".branch-claim-store", + "|| true", + "2>/dev/null", + ): + assert forbidden.lower() not in script.lower() + assert lab_step["env"] == { + "GH_TOKEN": "${{ github.token }}", + "REPO": "${{ github.repository }}", + "HEAD_SHA": "${{ github.event.pull_request.head.sha }}", + "LAB_CHECK_NAME": "${{ inputs.lab_check_name }}", + "LAB_CHECK_APP_SLUG": "${{ inputs.lab_check_app_slug }}", + "LAB_CHECK_WAIT_SECONDS": "${{ inputs.lab_check_wait_seconds }}", + "MODE": "${{ inputs.mode }}", + } + + +def test_input_defaults_and_permissions() -> None: + reusable = _workflow("branch-claim-check-reusable.yml") + # PyYAML's YAML 1.1 loader treats the unquoted Actions `on` key as True. + inputs = reusable[True]["workflow_call"]["inputs"] + for name, expected in ( + ("claim_source", "file"), + ("lab_check_name", CHECK_NAME), + ("lab_check_app_slug", APP_SLUG), + ("lab_check_wait_seconds", 900), + ): + assert inputs[name]["default"] == expected + assert inputs["lab_check_wait_seconds"]["type"] == "number" + caller = _workflow("branch-claim-check.yml") + # The caller names its source. It stays on `file` until a completed lab check is + # visible on an omniclaude head (see the caller's header); the value must be one the + # reusable workflow accepts. + assert caller["jobs"]["branch-claim-check"]["with"]["claim_source"] in { + "file", + "work-ledger", + } + assert caller["jobs"]["branch-claim-check"]["secrets"] == "inherit" + for workflow in (reusable, caller): + assert workflow["permissions"]["checks"] == "read" + assert workflow["permissions"]["contents"] == "read" + + +def test_unknown_source_fails(tmp_path: Path) -> None: + workflow = _workflow("branch-claim-check-reusable.yml") + step = next( + s + for s in workflow["jobs"]["branch-claim-check"]["steps"] + if s["name"] == "Validate the claim source" + ) + result = subprocess.run( + ["bash", "-c", step["run"]], + cwd=tmp_path, + env={**os.environ, "CLAIM_SOURCE": "unknown-source"}, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + assert result.returncode == 1 + assert "unknown-source" in result.stdout + assert "THE CHECK DID NOT RUN." in result.stdout + + +@pytest.mark.parametrize("mode", ["record", "refuse"]) +@pytest.mark.parametrize( + "runs", + [ + [], + [_check(app="github-actions")], + [_check(app="another-app")], + [_check(status="in_progress")], + ], + ids=["absent", "github-actions", "another-app", "pending"], +) +def test_no_completed_matching_run_fails(run_lab_step, runs, mode: str) -> None: + result = run_lab_step(runs, mode) + assert result.returncode == 1 + for required in ( + CHECK_NAME, + APP_SLUG, + HEAD_SHA, + "0 seconds", + "THE CHECK DID NOT RUN.", + ): + assert required in result.stdout + assert "::error::" in result.stdout + if runs and runs[0]["app"]["slug"] != APP_SLUG: + assert "Ignored 1 check run(s)" in result.stdout + + +def test_api_failure_is_not_a_pass_and_stderr_is_preserved(run_lab_step) -> None: + result = run_lab_step([], api_error="GitHub is unavailable") + assert result.returncode == 1 + assert "GitHub is unavailable" in result.stderr + assert "THE CHECK DID NOT RUN." in result.stdout + + +@pytest.mark.parametrize("mode", ["record", "refuse"]) +@pytest.mark.parametrize( + ("outcome", "refuse_exit", "annotation"), + [ + ("did-not-run", 1, "::error::"), + ("held-elsewhere", 1, "::warning::"), + ("fence-behind", 1, "::warning::"), + ("unidentified", 0, "::warning::"), + ("held-by-pusher", 0, ""), + ("unclaimed", 0, ""), + ("no-ticket", 0, ""), + ], +) +def test_outcomes_follow_mode(run_lab_step, outcome, refuse_exit, annotation, mode): + check = _check(outcome) + result = run_lab_step([check], mode) + expected = refuse_exit if mode == "refuse" or outcome == "did-not-run" else 0 + assert result.returncode == expected + assert CHECK_URL in result.stdout + summary = check["output"]["summary"] + assert summary.splitlines()[0] in result.stdout + if annotation: + assert annotation in result.stdout + assert "holder=peer-lane" in result.stdout + assert summary.splitlines()[1] in result.stdout + if outcome == "did-not-run": + assert ( + f"::error::{summary.splitlines()[1]} THE CHECK DID NOT RUN." + in result.stdout + ) + + +@pytest.mark.parametrize( + "summary", + [ + "garbled first line\nbranch-claim-outcome: unclaimed ticket=OMN-20708", + "branch-claim-outcome: unclaimed-extra ticket=OMN-20708", + "branch-claim-outcome: unknown ticket=OMN-20708", + "branch-claim-outcome: unclaimed", + "", + ], +) +def test_garbled_first_line_fails(run_lab_step, summary: str) -> None: + check = _check() + check["output"]["summary"] = summary + result = run_lab_step([check]) + assert result.returncode == 1 + assert CHECK_URL in result.stdout + assert summary.split("\n", 1)[0] in result.stdout + assert "THE CHECK DID NOT RUN." in result.stdout + + +def test_latest_completed_trusted_run_wins_across_pages(run_lab_step) -> None: + older = _check("did-not-run", completed_at="2026-10-07T11:00:00Z") + newer = _check("unclaimed") + unrelated = _check("did-not-run", app="github-actions") + unrelated["completed_at"] = "2026-10-07T13:00:00Z" + pending = _check("held-elsewhere", status="in_progress") + pending["completed_at"] = None + result = run_lab_step( + [], + "refuse", + pages=[{"check_runs": [newer, unrelated, pending]}, {"check_runs": [older]}], + ) + assert result.returncode == 0 + assert "branch-claim-outcome: unclaimed " in result.stdout + assert "branch-claim-outcome: did-not-run " not in result.stdout + assert "Ignored 1 check run(s)" in result.stdout + + +def test_work_ledger_source_runs_no_ledger_step() -> None: + """With `work-ledger` the only steps that run are the validator and the reader.""" + steps = _workflow("branch-claim-check-reusable.yml")["jobs"]["branch-claim-check"][ + "steps" + ] + running = [ + step["name"] + for step in steps + if "inputs.claim_source == 'file'" not in step.get("if", "") + ] + assert running == [ + "Validate the claim source", + "Read the lab branch-claim check for this head", + ] + + +def test_ledger_absent_passes( + run_lab_step, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No markdown ledger anywhere the reader can see: an unheld branch still passes.""" + monkeypatch.delenv("ONEX_LEDGER_PATH", raising=False) + assert not list(tmp_path.glob("**/ROLLING_WORK_LEDGER*")) + for outcome in ("held-by-pusher", "unclaimed", "no-ticket"): + result = run_lab_step([_check(outcome)], "refuse") + assert result.returncode == 0, result.stdout + assert f"branch-claim-outcome: {outcome} " in result.stdout + + +@pytest.mark.parametrize("outcome", ["held-elsewhere", "fence-behind"]) +def test_claimed_elsewhere_refused(run_lab_step, outcome: str) -> None: + refused = run_lab_step([_check(outcome)], "refuse") + assert refused.returncode == 1 + assert f"branch-claim-outcome: {outcome} " in refused.stdout + assert "holder=peer-lane" in refused.stdout + # `record` mode reports the same finding and does not fail the pull request. + recorded = run_lab_step([_check(outcome)], "record") + assert recorded.returncode == 0 + assert "::warning::" in recorded.stdout + + +def test_branch_claim_check_workflow_is_wired() -> None: + ci = _workflow("ci.yml") + gate = ci["jobs"]["branch-claim-gate"] + step = next(s for s in gate["steps"] if s.get("name") == WIRED_STEP_NAME) + assert TEST_PATH in step["run"] + assert "pytest" in step["run"] + for forbidden in ("|| true", "continue-on-error"): + assert forbidden not in step["run"] + assert "continue-on-error" not in step + assert "continue-on-error" not in gate + # The job runs only when its path filter matches, so the filter must name every + # file the reader's test reads. + filter_step = next( + s for s in ci["jobs"]["changes"]["steps"] if s.get("id") == "filter" + ) + paths = yaml.safe_load(filter_step["env"]["PATH_FILTERS"])["branch-claim-gate"][ + "paths" + ] + for needed in ( + TEST_PATH, + ".github/workflows/branch-claim-check.yml", + ".github/workflows/branch-claim-check-reusable.yml", + ".github/workflows/ci.yml", + ): + assert needed in paths + + config = yaml.safe_load((REPO_ROOT / ".pre-commit-config.yaml").read_text()) + hooks = [h for repo in config["repos"] for h in repo["hooks"] if h["id"] == HOOK_ID] + assert len(hooks) == 1 + hook = hooks[0] + assert TEST_PATH in hook["entry"] + assert hook["pass_filenames"] is False + assert hook["stages"] == ["pre-commit"] + for touched in ( + TEST_PATH, + ".github/workflows/branch-claim-check.yml", + ".github/workflows/branch-claim-check-reusable.yml", + ): + assert re.search(hook["files"], touched), touched From c6a21952f3f68cc8c617ca46e638c93132cbc8d2 Mon Sep 17 00:00:00 2001 From: Jonah Gray Date: Tue, 13 Oct 2026 09:17:56 -0400 Subject: [PATCH 2/2] fix(OMN-20708): add paths and trust boundary comment The CI check "Tests (Split 2/2)" failed on head 822752b04 at tests/workflows/test_blocking_validation_workflows.py::test_filter_is_the_original_trigger[branch-claim-gate]. The PR widened the Branch Claim Gate path filter in ci.yml with three paths: tests/scripts/test_branch_claim_check_workflow.py, .github/workflows/branch-claim-check.yml, and .pre-commit-config.yaml. This commit adds those three paths to the test's FOLDED_JOBS entry for branch-claim-gate, in the filter's order. The Hostile Reviewer gate blocked on one finding agreed by two voters: that the lab check outcome is spoofable via the check-runs API. This commit adds a comment above the "Read the lab branch-claim check for this head" step in branch-claim-check-reusable.yml stating the trust boundary: GitHub sets a check run's app from the creating token, so only the named App's installation token can publish a run the step accepts; GITHUB_TOKEN publishes as github-actions. The comment also notes jq @uri escapes & = # in the check name. No behaviour change in the workflow. Focused tests: 71 passed in test_blocking_validation_workflows.py (branch-claim/folded selection), 38 passed in test_branch_claim_check_workflow.py. --- .github/workflows/branch-claim-check-reusable.yml | 9 +++++++++ tests/workflows/test_blocking_validation_workflows.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/branch-claim-check-reusable.yml b/.github/workflows/branch-claim-check-reusable.yml index 616b407fd8..c54d2b3724 100644 --- a/.github/workflows/branch-claim-check-reusable.yml +++ b/.github/workflows/branch-claim-check-reusable.yml @@ -182,6 +182,15 @@ jobs: ;; esac + # TRUST BOUNDARY. A check run's `app` is set by GitHub from the token that + # created it, not from the request body: only an installation token of the + # named App can publish a check run attributed to it. A workflow's + # GITHUB_TOKEN publishes as `github-actions`, and a person with write + # access publishes as whatever App their token belongs to, so neither can + # post a run this step accepts. Runs from any other App are counted, + # reported and ignored. The check name is percent-encoded with jq's + # `@uri`, which escapes `&`, `=`, `#` and every other reserved character, + # so it cannot add query parameters. - name: Read the lab branch-claim check for this head if: inputs.claim_source == 'work-ledger' shell: bash diff --git a/tests/workflows/test_blocking_validation_workflows.py b/tests/workflows/test_blocking_validation_workflows.py index 8fa4d0d4dc..c1324a37f3 100644 --- a/tests/workflows/test_blocking_validation_workflows.py +++ b/tests/workflows/test_blocking_validation_workflows.py @@ -153,10 +153,15 @@ class _Folded: "scripts/hooks/pre-push-branch-claim", "tests/scripts/test_branch_claim.py", "tests/scripts/test_branch_claim_hook.py", + # OMN-20708: the lab-check reader's test, the caller and the + # pre-commit hook that runs it widen the gate's trigger. + "tests/scripts/test_branch_claim_check_workflow.py", "tests/scripts/test_lane_identity_canary.py", "scripts/hooks/prepare-commit-msg-lane", ".github/workflows/branch-claim-gate.yml", + ".github/workflows/branch-claim-check.yml", ".github/workflows/branch-claim-check-reusable.yml", + ".pre-commit-config.yaml", ), push=("main", "dev", "hotfix/**"), dispatch=False,