Skip to content

fix(ci-action): bind a fork PR review to its triggering run - #623

Draft
eyalgolan wants to merge 1 commit into
mainfrom
no-human/b4a1761d-2
Draft

eyalgolan wants to merge 1 commit into
mainfrom
no-human/b4a1761d-2

Conversation

@eyalgolan

@eyalgolan eyalgolan commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review passed (1 round) · Tests passed (13730 passed, 0 failed) · Merge policy ready

Evidence

Check Result
Independent review ✅ PASSED — 1 round · proof
Verifiers ✅ 1 of 1 satisfied · proof
Tests ✅ PASS — 13730 passed, 0 failed, 0 errors · proof
Merge policy ✅ ready — 6 of 6 rules satisfied · proof
Verifiers (1)
  • ✅ tests-assert-something — 1 file
Merge-ready policy (6 rules, source: default)
  • ✅ review_passed — review PASSED on head
  • ✅ tests_ran_and_passed — tests: 0 failed of 13730 run
  • ✅ tamper_guard_clear — tamper guard did not fire
  • ✅ repro_gate — repro gate pass
  • ✅ verifiers_all_satisfied — 1 verifiers, none failed
  • ✅ ci — ci: none reported (tolerated)

Acceptance criteria

  • The artifact is fetched by the triggering run id, not by name alone. MUTATION THAT MUST TURN THE SUITE RED: replace the run-id-scoped fetch with a name-only lookup and the tests must fail. State in the PR body which test caught it and paste its failure output.
  • A pull request whose head sha matches but whose number does not is REFUSED. MUTATION THAT MUST TURN THE SUITE RED: drop the number from the binding check, leaving only head-sha equality, and the tests must fail. The test must construct two distinct open pull requests sharing one head sha against different bases.
  • The fetched pull request is cross-checked against the run: its own head sha and its base must match what the workflow_run was triggered for, and a mismatch exits 2 with no comment posted. MUTATION THAT MUST TURN THE SUITE RED: remove the base comparison and the tests must fail.
  • A malformed or absent artifact fails closed with no comment posted and no review performed. Drive at least: absent file, empty file, non-JSON, a number that is not a positive integer, and a head sha that is not 40 hex. Each must exit 2; a test asserts no HTTP POST or PATCH was issued in any of those cases.
  • No behaviour change to the same-repository path: a test asserts a workflow_run event whose pull_requests array is NON-empty still resolves from the payload and never reads an artifact at all — asserted by observing that no artifact read was attempted, not by the absence of an error.

Changes

Final Report

Resuming context: The prior blocker (reviewer discarding a verdict without opening src/no_human/ci_action/github.py) was fixed on main by d2bf0c1, confirmed by the operator. My prior implementation was intact in the worktree at commit 85d44c8a1 on top of a merge of no-human/7f1660bb-2, but the branch was stale relative to current origin/main. I rebased: since no-human/7f1660bb-2's ci_action content was byte-identical to what landed on main as 4ea03f774, I resolved the three redundant intermediate-commit conflicts by taking the upstream content (verified identical beforehand), then git auto-dropped the fully-redundant final dependency commit as "patch contents already upstream." My feature commit now sits cleanly on top of current origin/main as a single commit 522304b6b, with a diff (vs 4ea03f774) touching exactly the 4 intended files: RELEASE_MANIFEST.txt, src/no_human/ci_action/github.py, src/no_human/ci_action/run.py, tests/test_ci_action.py (1118 insertions, 61 deletions) — no scope creep.

Rest of the coder's report (7 more paragraphs)

Implementation summary:

  • github.py: added GET-only allowlist anchors _RUN_ARTIFACTS_PATH (/actions/runs/{id}/artifacts) and _ARTIFACT_ZIP_PATH (/actions/artifacts/{id}/zip), deliberately excluding the name-scoped ?name= listing; find_run_artifact() fetches run-id-scoped, re-filters client-side on exact name, refuses on 0/>1 matches/expired/oversize; download_artifact_zip() follows exactly one redirect hop on a credential-free client, size-capped.
  • run.py: fork path (pull_requests empty) reads trusted workflow_run fields, resolves PR number via _resolve_pr_context (artifact bound to run id + head-sha check), fetches the pull by that number, then _assert_pull_matches_run cross-checks number/head-sha/head-repo/head-branch/base-repo/base-ref — all before the credential is read. Same-repo path untouched.

Mutation evidence (each applied, confirmed RED, then reverted — working tree verified clean afterward):

  1. Run-id-scoped find_run_artifact → name-only lookup: 3 tests failed, including test_fork_workflow_run_artifact_fetch_is_scoped_to_triggering_run_id — WriteSurfaceViolation: refused GET /repos/acme/widgets/actions/artifacts?per_page=100&name=pr-context ... (never the name-scoped .../actions/artifacts?name=... listing, which is not run-bound).
  2. Dropped the number comparison from _assert_pull_matches_run: test_pull_whose_number_disagrees_with_the_artifact_is_refused failed — assert 0 == 2 (ran to EXIT_OK instead of refusing). Full suite: 1 failed, 131 passed.
  3. Removed the base-repo/base-ref comparison: test_pull_base_repository_mismatch_is_refused and test_pull_base_ref_empty_is_refused both failed — assert 0 == 2.

Test run (final gate):

uv run pytest tests/test_ci_action.py -q -n 4
132 passed in 4.35s

uv run python scripts/check_release_manifest.py --strict → OK: 1712 file(s) match RELEASE_MANIFEST.txt.

(a paragraph matched a filtered-phrase list and was removed)

ASSUMPTIONS:

  • Resolved the stale-rebase conflicts by taking origin/main's content for the 3 intermediate dependency commits (verified byte-identical to my branch's pre-rebase no-human/7f1660bb-2 tip before doing so), then replaying only my own feature commit — a reversible, content-preserving resolution.

  • origin/main advanced by one more unrelated commit (81f3c2a5b, touching blockers/challenge.py/core/orchestrator.py) while I worked; I did not chase this further churn since it's outside scope and unrelated to ci_action.

  • MET — The artifact is fetched by the triggering run id, not by name alone — evidence: src/no_human/ci_action/github.py:413-427 (find_run_artifact builds /repos/{repo}/actions/runs/{run_id}/artifacts), github.py:90 (_RUN_ARTIFACTS_PATH allowlist excludes name-only shape). Mutation test: `test_fork

(summary truncated at 4000 characters — full report: nh task show <task-id>)

⚠️ Unresolved: the reviewer reached no verdict after 2 rounds (reviewer reached a verdict without referencing truncated changed file(s): src/no_human/ci_action/github.py). The review gate did not run, so this diff is unreviewed. Never a pass, and never a finding against the coder — a reviewer SESSION that died parks and runs the gate again, anything else goes to a human.

⚠️ Open question: Review the blocker and advise how to proceed.

Superseded PRs

Earlier attempts on this task opened these drafts and did not finish them:

How I verified this

Tests (11 runs, last shown) — git status echo "---" uv run pytest tests/test_ci_action.py -q -n 4 2>&1 | tail -10 · full log
On branch no-human/b4a1761d-2
nothing to commit, working tree clean
---
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
bringing up nodes...
bringing up nodes...

........................................................................ [ 54%]
............................................................             [100%]
132 passed in 4.35s
Lint (2 runs, last shown) — uv run python -m ruff check src/<redacted>/ci_action tests/test_ci_action.py 2>&1 | tail -40 · full log
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
/Users/eyalgolan/.<redacted>/worktrees/b4a1761d514340b0b539b6ee92dc4535.8407.0a350eb3/.venv/bin/python3: No module named ruff

13 commands recorded while working. Full verification log: ~/.no_human/artifacts/b4a1761d514340b0b539b6ee92dc4535/verification-attempt-2.md — nh logs b4a1761d; the same log, every command with its captured output, is posted as this PR's How I verified this comment when posting succeeds. Whether it passed is in the Evidence table above: no entry here asserts a pass or a fail.


Opened by no_human (attempt 2 of 3, no-human/b4a1761d-2 → main). It never merges: review and merge this yourself, or run nh approve b4a1761d.

@eyalgolan

Copy link
Copy Markdown
Contributor Author

How I verified this

13 commands recorded - as recorded (shortened, folded onto one line), grouped by kind. No entry asserts a pass or a fail: read the output. Not necessarily everything the session ran.

Not everything recorded is shown: the 12 most recent of those listed are shown with their captured output, and the other 1 command is shown as a command line only.

test

  • uv run pytest tests/test_ci_action.py -q -k "fork or workflow_run" 2>&1 | tail -80
    output not shown - see the note above.
  • uv run pytest tests/test_ci_action.py -q -n 4 2>&1 | tail -40
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
bringing up nodes...
bringing up nodes...

........................................................................ [ 54%]
............................................................             [100%]
132 passed in 9.23s
  • uv run pytest tests/test_review_gate_workflow.py -q 2>&1 | tail -20
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
ERROR: file or directory not found: tests/test_review_gate_workflow.py


no tests ran in 0.00s
  • uv run pytest tests/test_ci_action.py -q -k "fork or workflow_run" 2>&1 | tail -60
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10bb73dd0>

    def test_fork_workflow_run_resolves_pr_via_artifact_and_reviews(fork_workflow_env, monkeypatch):
        """The core fork-path happy path: no `pull_request` payload, no
        checkout — PR #7's identity is resolved from the `pr-context` artifact
        THIS run uploaded, cross-checked against the triggering `workflow_run`,
        and the review proceeds exactly as the same-repo path would."""
        monkeypatch.setattr(run, "review_diff", _fake_review_diff(_pass_decision()))
        handler, calls = _rest_handler(
            pulls={7: _pr_rest_payload(fork=True)},
            files_payload=[_pr_
[... 3,303 of 4,442 characters omitted from the middle ...]
acts?name=... listing, which is not run-bound)
=========================== short test summary info ============================
FAILED tests/test_ci_action.py::test_workflow_run_without_pull_requests_entry_fails_closed
FAILED tests/test_ci_action.py::test_fork_workflow_run_resolves_pr_via_artifact_and_reviews
FAILED tests/test_ci_action.py::test_fork_workflow_run_artifact_fetch_is_scoped_to_triggering_run_id
3 failed, 15 passed, 114 deselected in 1.39s

excerpt - 4,442 characters of output in total

  • uv run pytest tests/test_ci_action.py -q -k "fork or workflow_run" 2>&1 | tail -60
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
..................                                                       [100%]
18 passed, 114 deselected in 1.39s
  • uv run pytest tests/test_ci_action.py -q -k "test_pull_whose_number_disagrees_with_the_artifact_is_refused" 2>&1 | tail -60
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
F                                                                        [100%]
=================================== FAILURES ===================================
________ test_pull_whose_number_disagrees_with_the_artifact_is_refused _________

fork_workflow_env = {'event_path': PosixPath('/private/var/folders/1r/3r0rt1jd4j1456rsg_fh4d380000gn/T/pytest-of-eyalgolan/pytest-1039/tes...lders/1r/3r0rt1jd4j1456rsg_fh4d380000gn/T/pytest-of-eyalgolan/pytest-1039/test_pull_whose_number_disag
[... 1,346 of 2,485 characters omitted from the middle ...]
where <function main at 0x10baed620> = run.main
E        +  and   2 = run.EXIT_DID_NOT_RUN

tests/test_ci_action.py:1855: AssertionError
----------------------------- Captured stdout call -----------------------------
::add-mask::sk-ant-<redacted>
=========================== short test summary info ============================
FAILED tests/test_ci_action.py::test_pull_whose_number_disagrees_with_the_artifact_is_refused
1 failed, 131 deselected in 0.78s

excerpt - 2,486 characters of output in total

  • uv run pytest tests/test_ci_action.py -q -k "test_pull_base_repository_mismatch_is_refused or test_pull_base_ref_empty_is_refused" 2>&1 | tail -60
FF                                                                       [100%]
=================================== FAILURES ===================================
________________ test_pull_base_repository_mismatch_is_refused _________________

fork_workflow_env = {'event_path': PosixPath('/private/var/folders/1r/3r0rt1jd4j1456rsg_fh4d380000gn/T/pytest-of-eyalgolan/pytest-1040/tes...lders/1r/3r0rt1jd4j1456rsg_fh4d380000gn/T/pytest-of-eyalgolan/pytest-1040/test_pull_base_repository_mism0/summary.md')}
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x1097be4b0>

    def test_pull_base_repository_mismatch_is_refused(fork_workflow_env, monkeypatch):
        """IDENTITY (
[... 2,403 of 3,542 characters omitted from the middle ...]
 2 = run.EXIT_DID_NOT_RUN

tests/test_ci_action.py:1889: AssertionError
----------------------------- Captured stdout call -----------------------------
::add-mask::sk-ant-<redacted>
=========================== short test summary info ============================
FAILED tests/test_ci_action.py::test_pull_base_repository_mismatch_is_refused
FAILED tests/test_ci_action.py::test_pull_base_ref_empty_is_refused - assert ...
2 failed, 130 deselected in 0.55s

excerpt - 3,548 characters of output in total

  • uv run pytest tests/test_ci_action.py -q -n 4 2>&1 | tail -20
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
bringing up nodes...
bringing up nodes...

........................................................................ [ 54%]
............................................................             [100%]
132 passed in 3.97s
  • uv run pytest tests/test_ci_action.py -q --collect-only -k "malformed_pr_context" 2>&1 | tail -40
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[empty_file-]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[whitespace_only-   \n]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[not_json-not json at all]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[json_not_object-[1, 2, 3]]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[number_missing-{"head_sha": "dddddddddddddddddddddddddddddddddddddddd"}]
test
[... 1,048 of 2,187 characters omitted from the middle ...]
ils_closed[head_sha_too_long-{"number": 7, "head_sha": "ddddddddddddddddddddddddddddddddddddddddd"}]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[head_sha_uppercase-{"number": 7, "head_sha": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}]
tests/test_ci_action.py::test_malformed_pr_context_fails_closed[head_sha_non_hex-{"number": 7, "head_sha": "gggggggggggggggggggggggggggggggggggggggg"}]

15/132 tests collected (117 deselected) in 0.49s

excerpt - 2,185 characters of output in total

  • uv run pytest \ "tests/test_ci_action.py::test_fork_workflow_run_resolves_pr_via_artifact_and_reviews" \ "tests/test_ci_action.py::test_fork_workflow_run_artifact_fetch_is_scoped_to_triggering_run_id" [... 618 of 961 characters omitted from the middle ...] dddddddddddddddddddddddddddd"}]' \ "tests/test_ci_action.py::test_same_repo_workflow_run_never_reads_an_artifact" \ -q 2>&1 | tail -30
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
.........                                                                [100%]
9 passed in 0.61s
  • git status echo "---" uv run pytest tests/test_ci_action.py -q -n 4 2>&1 | tail -10
On branch no-human/b4a1761d-2
nothing to commit, working tree clean
---
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
bringing up nodes...
bringing up nodes...

........................................................................ [ 54%]
............................................................             [100%]
132 passed in 4.35s

lint

  • uv run ruff check src/<redacted>/ci_action tests/test_ci_action.py 2>&1 | tail -40
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
error: Failed to spawn: `ruff`
  Caused by: No such file or directory (os error 2)
  • uv run python -m ruff check src/<redacted>/ci_action tests/test_ci_action.py 2>&1 | tail -40
warning: `VIRTUAL_ENV=/Users/eyalgolan/git/<redacted>-public/.venv` does not match the project environment path `.venv` and will be ignored; use `--active` to target the active environment instead
/Users/eyalgolan/.<redacted>/worktrees/b4a1761d514340b0b539b6ee92dc4535.8407.0a350eb3/.venv/bin/python3: No module named ruff

Not verified: everything below is a limit of this section, listed whether or not it bit this attempt.

  • no command recognised as e2e, http, typecheck, build was recorded - and a recorded command is shown with its middle omitted, so a check inside the omitted part cannot be ruled out
  • 1 command listed above is shown without its captured output: only the 12 most recent carry it
  • an entry shows that a command LINE was submitted to the shell and what came back - never that the check recognised inside it RAN, and never that it was the RIGHT command: pytest -k test_nothing prints a clean run, and a recorded command line may name a check the shell never reached yet is still counted - TEN SHAPES WERE DRIVEN against bash 3.2.57 with the check replaced by a marker-printing stub and the marker was absent in every one: a failed &&, a taken ||, an exit, an exec, an exit inside a sourced script, a syntax error that aborts the REST of the line, a multi-line if false, a case that matches nothing, set -e aborting an earlier command, and set -u on an unset variable; that list is MEASURED, NOT EXHAUSTIVE, because this module is not bash, so a kind this section does NOT list as missing is a kind some recorded line named, which is not the same as a kind that ran
  • the text is the coder's: the session chose the command string and, through echo/printf, can choose the output too. Both are shown as inert text, and no entry ASSERTS a pass, a fail, or an exit status - pytest -q | tail -3 exits with tail's status, Error: Exit code 1 is a line IN THE OUTPUT, and where the harness reported a timeout or an interruption instead of output that report is appended to the captured text in square brackets. Read the output
  • recognition reads the command line ONLY - it never looks inside what a command runs, so bash -c 'uv run pytest -q' leaves no receipt at all while make test leaves one that names make and not the recipe it ran; and the other way, a check merely NAMED in a heredoc body, or in a quoted string that happens to spell a shell separator, can be recorded as though it ran
  • commands run inside a spawned subagent are deliberately excluded, so delegated work leaves no receipt here; a command the harness refused to run (blocked, or permission denied) leaves none, because it never ran; and only a command the HARNESS backgrounded leaves no receipt at all - it hands back a task id instead of output. A trailing & YOU wrote is NOT that and is NOT excluded: pytest -q & is recorded and headed test, and may still have been running when the harness returned
  • the COMMAND and the output are both redacted and bounded before they are stored - an excerpt is not the full log, a credential-shaped string may have been masked out of either, a command over 400 characters is shortened in the middle, each command is displayed on ONE line with its newlines folded to spaces (so it may not re-run as written), and invisible and direction-changing characters are stripped before display; look-alike letters are NOT detected
  • nothing here checks that these commands exercise the diff - no receipt is compared against the files this PR changes; no interactive UI check was performed (no_human never drives a browser at your change except testing/ui_evidence.py's walk, reported as its own evidence, not a receipt; the only other page it drives is a CI server's login form, and the board it opens without driving, so an e2e entry is the project's harness printing its result, not a human-style walkthrough); and no_human's own test run, CI, and the independent review are separate signals - this section covers only the coder session's own commands
  • at most 200 receipts are recorded per attempt; past that the observer stops recording, and this section says so above when the limit was reached

See the PR body's Evidence table for the orchestrator's own test run.

@eyalgolan

Copy link
Copy Markdown
Contributor Author

Independent review — PASSED (1 round) on 522304b

A different model, fresh context, commit, push and merge refused at the tool call, told to refute "done". This is the checklist the gate decided on; no_human never merges — a human does.

Severity Finding Where Note
✅ All five acceptance criteria met with mutation-killing tests src/no_human/ci_action/run.py:1287 Traced the fork path end to end and it holds up: the artifact is fetched strictly by run id, the pull is fetched by the artifact-claimed number and then cross-c
✅ security angle did not run (reached no verdict) — advisory — the extra angle pass was skipped; the main review still gates
✅ tests angle did not run (reached no verdict) — advisory — the extra angle pass was skipped; the main review still gates
✅ silent-failure angle did not run (reached no verdict) — advisory — the extra angle pass was skipped; the main review still gates
1 advisory finding (low/nit — never blocking)
Severity Finding Where Note
❌ low maintainability: duplicated client-construct + error-funnel across the two branches src/no_human/ci_action/run.py:1225 These two branches each open their own GitHubClient and wrap it in the same except (GitHubAPIError, WriteSurfaceViolation) -> identical `_fail("a GitHub API

@eyalgolan eyalgolan changed the title Fork PR review must bind the artifact to its triggering run fix(ci-action): bind a fork PR review to its triggering run Sep 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants