Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .github/actions/check-born-triaged/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,7 @@ 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.
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.
3 changes: 3 additions & 0 deletions .github/actions/check-born-triaged/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion .github/actions/check-born-triaged/check-born-triaged.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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}`);
}

Expand Down
34 changes: 30 additions & 4 deletions .github/actions/check-born-triaged/check-born-triaged.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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 {
Expand All @@ -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", () => {
Expand All @@ -181,5 +181,31 @@ 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), "");
assert.equal(humanIds({ version: 2, actors: [HUMAN] }), "", "a wrong-version list yields no ids");
});
37 changes: 36 additions & 1 deletion .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,49 @@ 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");
assert.equal(runs("false", ""), false);
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);
Expand Down
49 changes: 43 additions & 6 deletions .github/workflows/intake-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}

Expand All @@ -19,18 +30,25 @@ 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:
contents: read # check out the default branch for the trusted-actor list and models block
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 || '' }}
Expand Down Expand Up @@ -79,8 +97,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: >-
Expand All @@ -91,11 +125,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@ff8cd36321423de961381e608e4a31563e74adb3 # v0.49.0
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 }}
Loading