Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f0c859c
test(github): reproduce ambiguous delivery and unbound receipt failures
Oct 16, 2026
6e5c03d
fix(github): bind delivery receipts and reconcile ambiguous posts
Oct 16, 2026
d14f521
fix(github): retain recovery budgets and verify reconciled ownership
Oct 16, 2026
3c8621f
fix(github): scope delivery rounds to the pull request
Oct 16, 2026
24e0ee3
fix(github): preserve recovery after reads and defer ambiguous posts
Oct 16, 2026
5c61ad4
fix(github): reconcile delayed posts before rerun
Oct 16, 2026
1ecb42f
fix: bound automatic recovery and pin delivery ownership
Oct 16, 2026
7595117
fix: match configured check App in workflow fallback
Oct 16, 2026
511dc0b
fix: require current check App for same-head delivery
Oct 16, 2026
f823e19
test: include GitHub Actions identity in check fixtures
Oct 16, 2026
64b061b
fix: preserve explicit recheck commands and clarify app tokens
Oct 16, 2026
34cafb5
fix: align bundled check identity and recovery guidance
Oct 16, 2026
d426b97
fix: preserve delivery when GitHub history reads fail
Oct 16, 2026
d2334b7
fix: validate reconciliation receipt shape
Oct 16, 2026
4fab20f
fix: identify terminal workflow checks for reconciliation
Oct 16, 2026
9a22b0a
fix: trust receipts from the pinned GitHub App
Oct 16, 2026
35991e2
fix: keep prior review updates within the pinned App
Oct 16, 2026
ce48c9f
fix(github): isolate pinned App author trust
Oct 16, 2026
d41a68f
fix(explain): use the validated check App pin
Oct 16, 2026
db0f201
Merge main into codex/issue102-delivery-receipt
frankekn Oct 18, 2026
eec0a58
test: give main's fake gh an owned check app and a check-runs list
frankekn Oct 18, 2026
d782eb0
docs(github): explain why newer in-progress rounds invalidate the rec…
frankekn Oct 18, 2026
9d2c41c
fix(explain): read prior findings only from a delivered receipt
frankekn Oct 18, 2026
155a451
fix(github): keep workflow receipts out of recovery accounting
frankekn Oct 18, 2026
9a1b53c
test(explain): cover a completed check whose round does not match
frankekn Oct 18, 2026
69df42c
refactor(github): share one delivered-receipt check between explain a…
frankekn Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/commands.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run review.yml -R "${{ github.repository }}" -f pr_number="${{ github.event.issue.number }}"
gh workflow run review.yml -R "${{ github.repository }}" -f pr_number="${{ github.event.issue.number }}" -f recheck=true

explain:
# Runs on the ephemeral x64-review pool (on the tailnet, reaches CLIProxyAPI); same lane as review.
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/hosted-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
pr:
description: PR number to review
required: false
recheck:
description: Force a full review and reset the automatic recovery limit
type: boolean
required: false
default: false
pr_number:
description: PR number supplied by reconciliation
required: false
Expand All @@ -20,6 +25,7 @@ jobs:
uses: ./.github/workflows/review.yml
with:
pr_number: ${{ inputs.pr_number || inputs.pr }}
recheck: ${{ inputs.recheck || false }}
runner: codex
# Self-hosted lane via CLIProxyAPI (frank_infra x64-review pool); eval #172.
model: deepseek-v4.1-flash
Expand Down
58 changes: 54 additions & 4 deletions .github/workflows/review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ on:
pr_number:
description: PR number to review (manual trigger; bypasses pull_request webhook)
required: true
recheck:
description: Force a full review even when this head was already reviewed
type: boolean
required: false
default: false
runner:
description: "Model runner: codex, claude, opencode, grok, or pi"
required: false
Expand Down Expand Up @@ -51,6 +56,11 @@ on:
description: PR number (optional; defaults to the event PR)
type: string
required: false
recheck:
description: Force a full review even when this head was already reviewed
type: boolean
required: false
default: false
runner:
description: "Model runner: codex, claude, opencode, grok, or pi"
type: string
Expand Down Expand Up @@ -177,6 +187,9 @@ jobs:
-f head_sha="$head" \
-f status="completed" \
-f conclusion="neutral" \
-f external_id="$(jq -cn --argjson pr "$PR_NUM" \
Comment thread
frankekn marked this conversation as resolved.
Resolved
--arg run "$GITHUB_RUN_ID" --arg round "skip-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
'{v: 2, prNumber: $pr, roundId: $round, runId: $run}')" \
-f "output[title]=Needlefish: skipped (author not trusted)" \
-f "output[summary]=Needlefish runs model CLIs unrestricted on trusted runners, so it reviews only PRs opened by an OWNER, MEMBER, or COLLABORATOR that is not a bot. $skip_reason. A maintainer who has read this PR can review it by running the review workflow manually with allow_untrusted_author: true." >/dev/null
fi
Expand Down Expand Up @@ -327,7 +340,8 @@ jobs:
PI_AUTH_MODE: proxy
# npm-global bin is not on the runner service PATH.
PI_BIN: /home/termtek/.npm-global/bin/pi
NEEDLEFISH_RECHECK_INPUT: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }}
NEEDLEFISH_CHECK_APP_ID: ""
NEEDLEFISH_RECHECK_INPUT: ${{ inputs.recheck && '1' || '' }}
Comment thread
frankekn marked this conversation as resolved.
Resolved
Comment thread
frankekn marked this conversation as resolved.
Resolved
Comment thread
frankekn marked this conversation as resolved.
Resolved
NEEDLEFISH_ALLOW_UNTRUSTED_AUTHOR: ${{ inputs.allow_untrusted_author && '1' || '' }}
run: |
if [ -z "${NEEDLEFISH_BIN:-}" ] || [ ! -x "$NEEDLEFISH_BIN" ]; then
Expand Down Expand Up @@ -425,7 +439,22 @@ jobs:
const checks = JSON.parse(input).check_runs;
const previous = process.env.NEEDLEFISH_PREVIOUS_CHECK_ID ?? "";
if (!/^[0-9]+$/.test(previous)) process.exit(1);
const check = checks.filter(c => c.id > Number(previous) && c.name === "Needlefish" && c.external_id === process.env.GITHUB_RUN_ID && c.app?.slug === "github-actions").sort((a, b) => b.id - a.id)[0];
const appIdInput = process.env.NEEDLEFISH_CHECK_APP_ID || "";
const appId = appIdInput ? Number(appIdInput) : null;
if (appIdInput && (!/^[1-9][0-9]*$/.test(appIdInput) || !Number.isSafeInteger(appId))) process.exit(1);
const currentRun = externalId => {
if (externalId === process.env.GITHUB_RUN_ID) return true;
try {
const round = JSON.parse(externalId);
return round?.v === 2 && round.runId === process.env.GITHUB_RUN_ID && round.prNumber === Number(process.env.PR_NUM);
} catch { return false; }
};
const check = checks.filter(c =>
c.id > Number(previous) &&
c.name === "Needlefish" &&
currentRun(c.external_id) &&
(appId === null ? c.app?.slug === "github-actions" : c.app?.id === appId)
).sort((a, b) => b.id - a.id)[0];
const prefix = "Review errored and did NOT pass this PR.\n\n```\n";
const error = check?.output?.summary?.startsWith(prefix) ? check.output.summary.slice(prefix.length).split("\n")[0] : "";
const failed = check?.status === "completed" && check.conclusion === "failure" && check.output.title === "Needlefish: review failed";
Expand Down Expand Up @@ -478,6 +507,7 @@ jobs:
PR_NUM: ${{ inputs.pr_number || github.event.inputs.pr_number || github.event.pull_request.number }}
WORKFLOW_REF: ${{ github.workflow_ref }}
RUN_ID: ${{ github.run_id }}
NEEDLEFISH_CHECK_APP_ID: ""
run: |
set -eu
if [ -z "$PR_NUM" ]; then
Expand Down Expand Up @@ -506,14 +536,34 @@ jobs:
-f head_sha="$head" \
-f status="completed" \
-f conclusion="failure" \
-f external_id="$(jq -cn --argjson pr "$PR_NUM" \
--arg run "$RUN_ID" --arg round "retry-required-$RUN_ID-$GITHUB_RUN_ATTEMPT" \
'{v: 2, prNumber: $pr, roundId: $round, runId: $run}')" \
-f "output[title]=Needlefish: caller retry required" \
-f "output[summary]=$summary" >/dev/null
}
# filter=all: the default (latest) returns one check-run per name, and
# every review check is named "Needlefish", so the retry cap below would
# otherwise never see more than one failure and could never trip.
runs_url="repos/$REPO/commits/$head/check-runs?check_name=Needlefish&per_page=100&filter=all"
valid=$(gh api "$runs_url" --jq '[.check_runs[] | select(.conclusion == "success" or .conclusion == "neutral" or (.conclusion == "failure" and ((.output.title // "") | test("^Needlefish: review (failed|terminated)$") | not)))] | length')
app_id="${NEEDLEFISH_CHECK_APP_ID:-}"
if [ -n "$app_id" ] && { [[ ! "$app_id" =~ ^[1-9][0-9]*$ ]] || [ "${#app_id}" -gt 16 ] || [ "$app_id" -gt 9007199254740991 ]; }; then
echo "::error::NEEDLEFISH_CHECK_APP_ID must be a positive safe integer"
exit 1
fi
app_filter='.app.slug == "github-actions"'
if [ -n "$app_id" ]; then app_filter=".app.id == $app_id"; fi
owned_checks=$(gh api "$runs_url" --jq "[
.check_runs[]
| select(.name == \"Needlefish\" and ($app_filter))
| ((.external_id // \"\" | fromjson?) // null) as \$round
| select(if (\$round | type) == \"object\" then
Comment thread
frankekn marked this conversation as resolved.
Resolved
(\$round.v == 2 and \$round.prNumber == $PR_NUM
and (\$round.roundId | type) == \"string\" and (\$round.roundId | length) > 0
and (\$round.runId | type) == \"string\")
else ((.external_id // \"\") | test(\"^[1-9][0-9]*$\")) end)
Comment thread
frankekn marked this conversation as resolved.
Resolved
]")
valid=$(printf '%s' "$owned_checks" | jq '[.[] | select(.conclusion == "success" or .conclusion == "neutral" or (.conclusion == "failure" and ((.output.title // "") | test("^Needlefish: review (failed|terminated)$") | not)))] | length')
Comment thread
frankekn marked this conversation as resolved.
Resolved
if [ "$valid" -gt 0 ]; then
echo "head $head already has a terminal Needlefish verdict; nothing to reconcile"
exit 0
Expand Down Expand Up @@ -546,7 +596,7 @@ jobs:
echo "a $workflow_file run for PR #$PR_NUM is still active or queued; letting it finish"
exit 0
fi
infra_fails=$(gh api "$runs_url" --jq '[.check_runs[] | select(.conclusion == "failure" and ((.output.title // "") | test("^Needlefish: review (failed|terminated)$")))] | length')
infra_fails=$(printf '%s' "$owned_checks" | jq '[.[] | select(.conclusion == "failure" and ((.output.title // "") | test("^Needlefish: review (failed|terminated)$")))] | length')
if [ "$infra_fails" -ge 2 ]; then
echo "::error::head $head has $infra_fails infra-failed reviews; retry cap reached, not re-dispatching"
exit 0
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ runs:
-f head_sha="$head" \
-f status="completed" \
-f conclusion="neutral" \
-f external_id="$(jq -cn --argjson pr "$pr" \
--arg run "$GITHUB_RUN_ID" --arg round "skip-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
'{v: 2, prNumber: $pr, roundId: $round, runId: $run}')" \
-f "output[title]=Needlefish: skipped (author not trusted)" \
-f "output[summary]=Needlefish reviews only PRs opened by an OWNER, MEMBER, or COLLABORATOR that is not a bot. $reason. A maintainer who has read this PR can review it by running the workflow manually with allow_untrusted_author: true." >/dev/null
fi
Expand Down
15 changes: 15 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ jobs:
uses: ./.github/workflows/needlefish-review-local.yml
with:
pr_number: ${{ inputs.pr_number || github.event.pull_request.number }}
recheck: ${{ inputs.recheck || false }}
runner: codex
model: gpt-5.6-terra
codex_reasoning_effort: xhigh
Expand All @@ -289,6 +290,20 @@ jobs:
The caller must retain `workflow_dispatch.inputs.pr_number`, `actions: write`,
and a run name ending in ` PR #<number>` for bounded reconciliation. Reconcile
runs on GitHub-hosted Ubuntu independently of the review runner.
Automatic dispatches use bounded recovery. To force a full manual review, declare
an optional boolean `workflow_dispatch.inputs.recheck` with default `false` in
the caller and forward it through `with.recheck` as shown above.

For direct CLI use with a custom GitHub App, provide its installation token as
`GH_TOKEN` and set `NEEDLEFISH_CHECK_APP_ID` to that App's numeric ID.
Use the ID from its GitHub App settings or the `app.id` field of a check created
by that App. The pin validates check ownership; it does not select or mint a token.
The bundled reusable workflow uses `secrets.GITHUB_TOKEN` and does not accept a
custom App token. It clears `NEEDLEFISH_CHECK_APP_ID` for review and reconciliation,
so repository variables and runner environment pins cannot override its GitHub
Actions identity. Custom App pins apply only to direct CLI use, where a mismatched
token and App pin fails closed.
Without a pin, new checks and recovery accept only GitHub Actions checks.
Set the repository variable `NEEDLEFISH_RUNS_ON` to an operator-owned runner
label when direct PR triggers should use a dedicated fleet; otherwise the
workflow keeps the `self-hosted` default. Explicit `runs_on` inputs still win.
Expand Down
14 changes: 14 additions & 0 deletions docs/reference.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ jobs:
uses: ./.github/workflows/needlefish-review-local.yml
with:
pr_number: ${{ inputs.pr_number || github.event.pull_request.number }}
recheck: ${{ inputs.recheck || false }}
runner: codex
model: gpt-5.6-terra
codex_reasoning_effort: xhigh
Expand All @@ -219,6 +220,19 @@ jobs:
Caller 保留 `workflow_dispatch.inputs.pr_number`、`actions: write`,以及以
` PR #<編號>` 結尾的 run-name,供有次數上限的 reconciliation 使用。Reconcile
維持在 GitHub-hosted Ubuntu 執行,不依賴 review runner 是否正常。
自動 dispatch 只做有次數上限的 recovery。若要手動強制完整 review,在 caller
宣告選填的 boolean `workflow_dispatch.inputs.recheck`(預設 `false`),並如上例
經由 `with.recheck` 傳入。

直接以 CLI 搭配自訂 GitHub App 時,以該 App 的 installation token 作為
`GH_TOKEN`,並把 `NEEDLEFISH_CHECK_APP_ID` 設為該 App 的數字 ID。ID 可從
GitHub App 設定頁,或該 App 建立之 check 的 `app.id` 欄位取得。此 pin 只驗證
check 的擁有者,不會選擇或產生 token。內建 reusable workflow 使用
`secrets.GITHUB_TOKEN`,不接受自訂 App token;它在 review 與 reconciliation
時清除 `NEEDLEFISH_CHECK_APP_ID`,因此 repository variable 與 runner 環境的
pin 都無法覆蓋其 GitHub Actions 身分。自訂 App pin 只適用於直接 CLI 使用;
token 與 App pin 不符時 fail closed。
未設定 pin 時,新 check 與 recovery 只接受 GitHub Actions 建立的 check。
若 direct PR trigger 要使用專用 fleet,設定 repository variable
`NEEDLEFISH_RUNS_ON` 為操作者持有的 runner label;未設定時維持 `self-hosted`。
明確傳入的 `runs_on` 仍優先。
Expand Down
14 changes: 14 additions & 0 deletions scripts/commands-workflow-auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,17 @@ test("malformed permission payload is denied fail-closed", () => {
assert.notEqual(lastOutput(result.githubOutput, "allowed"), "true");
assert.match(result.stderr, /Invalid permission payload/);
});

test("manual recheck dispatch explicitly requests a full review", () => {
const dispatch = workflowScript("Dispatch recheck")
.replaceAll("${{ github.repository }}", "acme/widgets")
.replaceAll("${{ github.event.issue.number }}", "42");
const result = spawnSync("bash", ["-c", `gh() { printf '%s\\n' "$@"; }\n${dispatch}`], {
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.stdout.trim().split("\n"), [
"workflow", "run", "review.yml", "-R", "acme/widgets",
"-f", "pr_number=42", "-f", "recheck=true",
]);
});
50 changes: 30 additions & 20 deletions scripts/review-workflow-author-trust.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ esac
...process.env,
GH_LOG: ghLog,
GH_TOKEN: "test-token",
GITHUB_RUN_ID: "777",
GITHUB_RUN_ATTEMPT: "1",
GITHUB_OUTPUT: output,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
REPO,
Expand Down Expand Up @@ -308,26 +310,34 @@ test("reconcile treats the posted skip check as terminal and never dispatches th
undefined,
);

const skipped = runStep(resolveScript, eventEnv({ association: "NONE" }));
assert.equal(skipped.posts.length, 1, skipped.log);
const posted = postedCheck(skipped.posts[0]);
const r = runStep(
reconcileScript,
{
PR_NUM: "42",
WORKFLOW_REF: `${REPO}/.github/workflows/review.yml@refs/heads/main`,
RUN_ID: "777",
},
{
pr: pull({ association: "NONE" }),
checkRuns: [
{ conclusion: posted.conclusion, output: { title: posted["output[title]"] } },
],
},
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /already has a terminal Needlefish verdict/);
assert.doesNotMatch(r.log, /^workflow run/m);
for (const [producer, env] of [
[resolveScript, eventEnv({ association: "NONE" })],
[trustStep.run, actionEnv({ association: "NONE" })],
]) {
const skipped = runStep(producer, env);
assert.equal(skipped.posts.length, 1, skipped.log);
const posted = postedCheck(skipped.posts[0]);
assert.deepEqual(JSON.parse(posted.external_id), {
v: 2, prNumber: 42, roundId: "skip-777-1", runId: "777",
});
const r = runStep(
reconcileScript,
{
PR_NUM: "42",
WORKFLOW_REF: `${REPO}/.github/workflows/review.yml@refs/heads/main`,
RUN_ID: "777",
},
{
pr: pull({ association: "NONE" }),
checkRuns: [
{ name: posted.name, app: { id: 15368, slug: "github-actions" }, external_id: posted.external_id, conclusion: posted.conclusion, output: { title: posted["output[title]"] } },
],
},
);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /already has a terminal Needlefish verdict/);
assert.doesNotMatch(r.log, /^workflow run/m);
}
});

function actionEnv({ association, userType = "User", allow = "false", eventHead = HEAD }) {
Expand Down
31 changes: 30 additions & 1 deletion scripts/review-workflow-fallback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ function shq(value) {
}

// primary/fallback: { out, err, code } the stub emits on that lane.
function runReviewScript(t, { primary, fallback, checks = [], checkResponse, checkExit = 0, runner = "codex", tier = "", previousCheckId = 0, snapshotExit = 0 }) {
function runReviewScript(t, {
primary, fallback, checks = [], checkResponse, checkExit = 0, runner = "codex",
tier = "", previousCheckId = 0, snapshotExit = 0, appId = "",
}) {
const root = mkdtempSync(join(tmpdir(), "needlefish-workflow-fallback-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const write = (name, content) => {
Expand Down Expand Up @@ -101,6 +104,7 @@ function runReviewScript(t, { primary, fallback, checks = [], checkResponse, che
NEEDLEFISH_TIMEOUT_MS_INPUT: "",
OPENCODE_IDLE_TIMEOUT_MS_INPUT: "",
NEEDLEFISH_RECHECK_INPUT: "",
NEEDLEFISH_CHECK_APP_ID: appId,
},
});
return {
Expand Down Expand Up @@ -307,6 +311,9 @@ test("installed runner auth misclassification uses this run check and Sol medium
test("check lookup stays closed for old runs, other apps, nonterminal checks and verdicts", t => {
for (const checks of [
[runnerCheck({ external_id: "older-run" })],
[runnerCheck({ external_id: JSON.stringify({ v: 2, prNumber: 7, runId: "older-run", roundId: "round", recoveryAttempt: 0 }) })],
[runnerCheck({ external_id: JSON.stringify({ v: 1, runId: "123" }) })],
[runnerCheck({ external_id: JSON.stringify({ v: 2, prNumber: 8, runId: "123", roundId: "other", recoveryAttempt: 0 }) })],
[runnerCheck({ app: { slug: "other-app" } })],
[runnerCheck({ status: "in_progress" })],
[runnerCheck({ output: { title: "Needlefish: changes requested", summary: "codex runner exited 1; 429" } })],
Expand Down Expand Up @@ -357,3 +364,25 @@ test("compatibility fallback requires a check created after this invocation snap
assert.equal(result.status, expected === 2 ? 0 : 1, result.stderr);
}
});

test("versioned delivery metadata preserves this workflow run fallback", t => {
const result = runReviewScript(t, {
primary: { out: "", err: "", code: 1 },
fallback: { out: outcome({ outcome: "verdict", verdict: "pass" }), err: "", code: 0 },
checks: [runnerCheck({ external_id: JSON.stringify({ v: 2, prNumber: 7, runId: "123", roundId: "round", recoveryAttempt: 0 }) })],
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.invocations.length, 2);
assert.match(result.invocations[1], /--model gpt-6\.1-sol/);
});

test("compatibility fallback honors the configured check App ID", t => {
const primary = { out: "", err: "", code: 1 };
const fallback = { out: "PASS", err: "", code: 0 };
const custom = runnerCheck({ app: { id: 4812, slug: "private-review-app" } });
for (const [appId, expected] of [["4812", 2], ["9999", 1], ["", 1], ["invalid", 1]]) {
const result = runReviewScript(t, { primary, fallback, checks: [custom], appId });
assert.equal(result.invocations.length, expected, `App ID ${appId}: ${result.stderr}`);
assert.equal(result.status, expected === 2 ? 0 : 1, result.stderr);
}
});
Loading
Loading