From f1e386a61c1e826ea630a6dd8b7e5fcaabe02b63 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:19:48 -0400 Subject: [PATCH 1/3] fix(intake): admit listed humans by id and triage dispatched issues The reusable intake lane's author gate read author_association, which a webhook reports as CONTRIBUTOR for the org owner's private membership, so every intake run skipped triage. Pass allowed-author-ids from the trusted-actor list's human ids, which check-born-triaged now emits. Add workflow_dispatch with an issue-number input, since issues a job's GITHUB_TOKEN files start no issues run. github-actions[bot] (41898282) and the lanes App are admitted as dispatching bots. Pin is a save point on an unreleased ci-workflows commit; repin to the release that carries ci-workflows#773 and #774 before opening the PR. Refs: #7886 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bQ8pL7fJgVy7YjQ7wK1Zw --- .github/actions/check-born-triaged/README.md | 1 + .github/actions/check-born-triaged/action.yml | 3 ++ .../check-born-triaged/check-born-triaged.mjs | 14 ++++++- .../check-born-triaged.test.mjs | 33 +++++++++++++++-- .github/workflows/intake-triage.yml | 37 ++++++++++++++++++- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/.github/actions/check-born-triaged/README.md b/.github/actions/check-born-triaged/README.md index 47e717afa8..04cb685099 100644 --- a/.github/actions/check-born-triaged/README.md +++ b/.github/actions/check-born-triaged/README.md @@ -33,5 +33,6 @@ on gh older than 2.94 sets a `type:` label instead of an Issue Type, so its item |---|---| | `skip` | `true` to skip intake, otherwise `false` | | `reason` | `born-triaged`, `no-marker`, `untrusted-author`, `no-type`, `no-status`, `list-unreadable` or `issue-unreadable` | +| `human-ids` | The list's `kind: human` ids, comma-joined, for the intake lane's `allowed-author-ids`; empty when the list is unreadable | The step always exits 0. A failed gate job leaves `skip` empty, and the callers' intake jobs run. diff --git a/.github/actions/check-born-triaged/action.yml b/.github/actions/check-born-triaged/action.yml index 65af1f2b72..d548335255 100644 --- a/.github/actions/check-born-triaged/action.yml +++ b/.github/actions/check-born-triaged/action.yml @@ -32,6 +32,9 @@ outputs: reason: description: "`born-triaged`, `no-marker`, `untrusted-author`, `no-type`, `no-status`, `list-unreadable` or `issue-unreadable`." value: ${{ steps.check.outputs.reason }} + human-ids: + description: The trusted-actor list's `kind` human ids, comma-joined; empty when the list is unreadable. + value: ${{ steps.check.outputs.human-ids }} runs: using: composite diff --git a/.github/actions/check-born-triaged/check-born-triaged.mjs b/.github/actions/check-born-triaged/check-born-triaged.mjs index 615a317e76..6ab9c8a051 100644 --- a/.github/actions/check-born-triaged/check-born-triaged.mjs +++ b/.github/actions/check-born-triaged/check-born-triaged.mjs @@ -30,6 +30,18 @@ function isTrustedAuthor(id, list, lanesAppId) { return list.actors.some((actor) => actor.id === id && actor.kind === "human"); } +// The listed humans' ids, comma-joined, for the intake lane's +// allowed-author-ids. Empty when the list is unreadable. +export function humanIds(list) { + if (!isListReadable(list)) { + return ""; + } + return list.actors + .filter((actor) => actor.kind === "human" && Number.isInteger(actor.id) && actor.id > 0) + .map((actor) => actor.id) + .join(","); +} + export function checkBornTriaged({ issue, list, lanesAppId }) { if (!isListReadable(list)) { return triage("list-unreadable"); @@ -110,7 +122,7 @@ async function main() { const result = /^[1-9][0-9]*$/.test(ISSUE_NUMBER ?? "") ? await run({ fetchIssue, list, lanesAppId: LANES_APP_ID }) : triage("issue-unreadable"); - writeOutputs(result); + writeOutputs({ ...result, "human-ids": humanIds(list) }); console.log(`check-born-triaged: skip=${result.skip} reason=${result.reason}`); } diff --git a/.github/actions/check-born-triaged/check-born-triaged.test.mjs b/.github/actions/check-born-triaged/check-born-triaged.test.mjs index 42aa6b60d1..faeb78889b 100644 --- a/.github/actions/check-born-triaged/check-born-triaged.test.mjs +++ b/.github/actions/check-born-triaged/check-born-triaged.test.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { BORN_TRIAGED_MARKER, checkBornTriaged, run } from "./check-born-triaged.mjs"; +import { BORN_TRIAGED_MARKER, checkBornTriaged, humanIds, run } from "./check-born-triaged.mjs"; const SCRIPT = fileURLToPath(new URL("./check-born-triaged.mjs", import.meta.url)); @@ -160,7 +160,7 @@ function runCli(env) { encoding: "utf8", }); const outputs = Object.fromEntries( - [...readFileSync(outputPath, "utf8").matchAll(/^(\w+)<<(\S+)\n([^\n]*)\n\2$/gm)].map((m) => [m[1], m[3]]), + [...readFileSync(outputPath, "utf8").matchAll(/^([\w-]+)<<(\S+)\n([^\n]*)\n\2$/gm)].map((m) => [m[1], m[3]]), ); return { status: result.status, outputs }; } finally { @@ -171,7 +171,7 @@ function runCli(env) { test("CLI triages a non-numeric issue number and exits 0", () => { const { status, outputs } = runCli({ ISSUE_NUMBER: "7; echo", TRUSTED_ACTORS_PATH: "/nonexistent" }); assert.equal(status, 0); - assert.deepEqual(outputs, { skip: "false", reason: "issue-unreadable" }); + assert.deepEqual(outputs, { skip: "false", reason: "issue-unreadable", "human-ids": "" }); }); test("CLI triages an unreadable trusted-actor list and exits 0", () => { @@ -181,5 +181,30 @@ test("CLI triages an unreadable trusted-actor list and exits 0", () => { GITHUB_API_URL: "http://127.0.0.1:9", }); assert.equal(status, 0); - assert.deepEqual(outputs, { skip: "false", reason: "list-unreadable" }); + assert.deepEqual(outputs, { skip: "false", reason: "list-unreadable", "human-ids": "" }); +}); + +test("CLI emits the listed human ids even when the issue is unreadable", () => { + const dir = mkdtempSync(path.join(tmpdir(), "born-triaged-list-")); + try { + const listPath = path.join(dir, "trusted-actors.json"); + writeFileSync(listPath, JSON.stringify(LIST)); + const { status, outputs } = runCli({ + ISSUE_NUMBER: "7", + TRUSTED_ACTORS_PATH: listPath, + GITHUB_API_URL: "http://127.0.0.1:9", + }); + assert.equal(status, 0); + assert.deepEqual(outputs, { skip: "false", reason: "issue-unreadable", "human-ids": "153232337" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("humanIds lists only the human actors' ids", () => { + const second = { id: 42, login: "other", kind: "human" }; + assert.equal(humanIds({ version: 1, actors: [HUMAN, LISTED_BOT, LANES_APP, second] }), "153232337,42"); + assert.equal(humanIds({ version: 1, actors: [LISTED_BOT] }), ""); + assert.equal(humanIds({ version: 1, actors: [{ id: "153232337", login: "x", kind: "human" }, { id: 0, login: "y", kind: "human" }] }), ""); + assert.equal(humanIds(null), ""); }); diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index fb96689fde..e2146165b9 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -5,9 +5,20 @@ name: intake-triage # only allowlisted labels that exist here, or `needs-human` when the run # escalates. Advisory: never blocks a merge. See ci-workflows' # intake-triage.yml for the security model and inputs. +# +# workflow_dispatch triages one issue by number: issues a job's GITHUB_TOKEN +# creates (the lane sweep's) start no issues run. The reusable admits a +# dispatch only from an actor whose id is in allowed-author-ids or +# allowed-bot-sender-ids, and reads the issue from the API. on: issues: types: [opened] + workflow_dispatch: + inputs: + issue-number: + description: The issue to triage. + type: string + required: true permissions: {} @@ -31,6 +42,9 @@ jobs: issues: read # read the issue's current body, type and labels outputs: skip: ${{ steps.check.outputs.skip }} + # The trusted-actor list's human ids, for the reusable's author gate. + human-ids: ${{ steps.check.outputs.human-ids }} + bot-ids: ${{ steps.bots.outputs.ids }} # Empty when the profile sets a subagent model, which the reusable # cannot apply; the refusal step below shows why. claude-args: ${{ steps.models.outputs.settings-json == '' && steps.models.outputs.claude-args || '' }} @@ -79,8 +93,24 @@ jobs: id: check uses: ./.base/.github/actions/check-born-triaged # zizmor: ignore[self-repository] with: + issue-number: ${{ github.event.issue.number || inputs.issue-number }} lanes-app-id: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + # Bots the reusable admits: github-actions[bot] (41898282), the actor of + # a dispatch sent with a GITHUB_TOKEN, and the lanes App when its id is + # set. An issue the GITHUB_TOKEN opens starts no issues run, so listing + # 41898282 admits only its dispatches. + - name: Compose the bot id list + id: bots + env: + LANES_APP_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + run: | + ids=41898282 + if printf '%s' "$LANES_APP_ID" | grep -Eq '^[1-9][0-9]*$'; then + ids="$ids,$LANES_APP_ID" + fi + echo "ids=$ids" >> "$GITHUB_OUTPUT" + intake-triage: needs: born-triaged if: >- @@ -91,11 +121,14 @@ jobs: contents: read # check out the default branch so the lane can read AGENTS.md issues: write # apply labels and post the triage comment concurrency: - group: intake-triage-${{ github.event.issue.number || github.run_id }} + group: intake-triage-${{ github.event.issue.number || inputs.issue-number || github.run_id }} cancel-in-progress: false - uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@3c8a4b2aeff5871cf72d35a686692bc6f0d44815 # v0.45.0 + uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@01c62449ee2991532d79759e99d862e1805300e6 # 01c6244 2026-10-18 with: runner: ubuntu-24.04 claude-args: ${{ needs.born-triaged.outputs.claude-args }} + issue-number: ${{ inputs.issue-number }} + allowed-author-ids: ${{ needs.born-triaged.outputs.human-ids }} + allowed-bot-sender-ids: ${{ needs.born-triaged.outputs.bot-ids }} secrets: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} From 15bd277e130ef56b2d94ae8dc5ed923d9a34229b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:50:33 -0400 Subject: [PATCH 2/3] ci(intake): pin the intake reusable to ci-workflows v0.49.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019bQ8pL7fJgVy7YjQ7wK1Zw --- .github/workflows/intake-triage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index e2146165b9..82f1e3c887 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -123,7 +123,7 @@ jobs: concurrency: group: intake-triage-${{ github.event.issue.number || inputs.issue-number || github.run_id }} cancel-in-progress: false - uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@01c62449ee2991532d79759e99d862e1805300e6 # 01c6244 2026-10-18 + uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@ff8cd36321423de961381e608e4a31563e74adb3 # v0.49.0 with: runner: ubuntu-24.04 claude-args: ${{ needs.born-triaged.outputs.claude-args }} From 6fa815c1bc820898726be576fb1369c1244dbf71 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:29:36 -0400 Subject: [PATCH 3/3] fix(intake): run a dispatched intake only from main A workflow_dispatch from another branch would read that branch's trusted-actor list, models block and AGENTS.md. The born-triaged gate now runs only for an issues event or a dispatch on refs/heads/main; skipped, it leaves claude-args empty and intake does not run. Also corrects the failed-gate text (the reusable now turns away every person when human-ids is empty), quotes the human-ids output description, and tests the id wiring, the bot id list and a wrong-version trusted-actor list. Co-Authored-By: Claude Opus 5.5 --- .github/actions/check-born-triaged/README.md | 3 +- .github/actions/check-born-triaged/action.yml | 2 +- .../check-born-triaged.test.mjs | 1 + .../actions/resolve-config/callers.test.mjs | 37 ++++++++++++++++++- .github/workflows/intake-triage.yml | 12 ++++-- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.github/actions/check-born-triaged/README.md b/.github/actions/check-born-triaged/README.md index 04cb685099..5c61d0956e 100644 --- a/.github/actions/check-born-triaged/README.md +++ b/.github/actions/check-born-triaged/README.md @@ -35,4 +35,5 @@ on gh older than 2.94 sets a `type:` label instead of an Issue Type, so its item | `reason` | `born-triaged`, `no-marker`, `untrusted-author`, `no-type`, `no-status`, `list-unreadable` or `issue-unreadable` | | `human-ids` | The list's `kind: human` ids, comma-joined, for the intake lane's `allowed-author-ids`; empty when the list is unreadable | -The step always exits 0. A failed gate job leaves `skip` empty, and the callers' intake jobs run. +The step always exits 0. A failed gate job leaves `skip` and `human-ids` empty, so the callers' +intake jobs fall through to the reusable, which turns away every person. diff --git a/.github/actions/check-born-triaged/action.yml b/.github/actions/check-born-triaged/action.yml index d548335255..83040b4465 100644 --- a/.github/actions/check-born-triaged/action.yml +++ b/.github/actions/check-born-triaged/action.yml @@ -33,7 +33,7 @@ outputs: description: "`born-triaged`, `no-marker`, `untrusted-author`, `no-type`, `no-status`, `list-unreadable` or `issue-unreadable`." value: ${{ steps.check.outputs.reason }} human-ids: - description: The trusted-actor list's `kind` human ids, comma-joined; empty when the list is unreadable. + description: "The trusted-actor list's `kind: human` ids, comma-joined; empty when the list is unreadable." value: ${{ steps.check.outputs.human-ids }} runs: diff --git a/.github/actions/check-born-triaged/check-born-triaged.test.mjs b/.github/actions/check-born-triaged/check-born-triaged.test.mjs index faeb78889b..2801bd224e 100644 --- a/.github/actions/check-born-triaged/check-born-triaged.test.mjs +++ b/.github/actions/check-born-triaged/check-born-triaged.test.mjs @@ -207,4 +207,5 @@ test("humanIds lists only the human actors' ids", () => { assert.equal(humanIds({ version: 1, actors: [LISTED_BOT] }), ""); assert.equal(humanIds({ version: 1, actors: [{ id: "153232337", login: "x", kind: "human" }, { id: 0, login: "y", kind: "human" }] }), ""); assert.equal(humanIds(null), ""); + assert.equal(humanIds({ version: 2, actors: [HUMAN] }), "", "a wrong-version list yields no ids"); }); diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 226840b84f..d1cb3a5d67 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -147,7 +147,7 @@ test("intake-triage.yml: intake runs only with claude-args resolved from triage- "needs.born-triaged.outputs.claude-args": claudeArgs, }); const args = "--model sonnet --max-turns 30 --effort medium"; - assert.equal(runs("", args), true, "a failed or false born-triaged check still runs intake"); + assert.equal(runs("", args), true, "a failed or false born-triaged check falls through to the reusable's gate"); assert.equal(runs("false", args), true); assert.equal(runs("true", args), false, "a born-triaged issue skips intake"); assert.equal(runs("", ""), false, "intake never runs without resolved claude-args"); @@ -155,6 +155,41 @@ test("intake-triage.yml: intake runs only with claude-args resolved from triage- assert.equal(intake.with["claude-args"], "${{ needs.born-triaged.outputs.claude-args }}"); }); +test("intake-triage.yml: a dispatch runs only from main and passes the gate's ids", () => { + const { jobs } = workflow("intake-triage.yml"); + const gate = jobs["born-triaged"]; + const runsOn = (event, ref) => evaluate(gate.if, { "github.event_name": event, "github.ref": ref }); + assert.equal(runsOn("issues", "refs/heads/main"), true); + assert.equal(runsOn("workflow_dispatch", "refs/heads/main"), true); + assert.equal(runsOn("workflow_dispatch", "refs/heads/feature"), false, "a dispatch from a branch reads no branch's trusted-actor list"); + + const intake = jobs["intake-triage"]; + assert.equal(intake.with["allowed-author-ids"], "${{ needs.born-triaged.outputs.human-ids }}"); + assert.equal(intake.with["allowed-bot-sender-ids"], "${{ needs.born-triaged.outputs.bot-ids }}"); + assert.equal(intake.with["issue-number"], "${{ inputs.issue-number }}"); + + const bots = gate.steps.find((step) => step.id === "bots"); + assert.equal(gate.outputs["bot-ids"], "${{ steps.bots.outputs.ids }}"); + const dir = mkdtempSync(join(tmpdir(), "bot-ids-")); + try { + const ids = (lanesAppId) => { + const output = join(dir, "output"); + writeFileSync(output, ""); + const result = spawnSync("bash", ["-c", bots.run], { + env: { ...process.env, LANES_APP_ID: lanesAppId, GITHUB_OUTPUT: output }, + }); + assert.equal(result.status, 0); + return readFileSync(output, "utf8"); + }; + assert.equal(ids(""), "ids=41898282\n"); + assert.equal(ids("123456"), "ids=41898282,123456\n"); + assert.equal(ids("12a; echo x"), "ids=41898282\n", "a malformed lanes App id is dropped"); + assert.equal(ids("0"), "ids=41898282\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + // address-feedback's head-sha output is empty after a gate stop. test("pr-unblock.yml: publish-feedback is reported neutral when address-feedback ran no feedback round", async () => { const head = "c".repeat(40); diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 82f1e3c887..caed8631b7 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -30,11 +30,15 @@ permissions: {} jobs: # Skips intake for an issue /work-items:decompose published already # triaged; see .github/actions/check-born-triaged. A failed gate leaves - # skip empty, so intake runs. The job also resolves the lane's model, turns - # and effort from the triage-issue route of the default branch's models - # block; when that fails, claude-args is empty and intake does not run, so - # it never falls back to the CLI's own model and an unbounded turn budget. + # skip empty, so intake falls through to the reusable, which turns away + # every person when human-ids is empty too. The job also resolves the lane's + # model, turns and effort from the triage-issue route of the default + # branch's models block; when that fails, claude-args is empty and intake + # does not run, so it never falls back to the CLI's own model and an + # unbounded turn budget. A dispatch runs only from main, so the trusted-actor + # list, the models block and the lane's AGENTS.md come from the default branch. born-triaged: + if: github.event_name == 'issues' || github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: