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
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/feat-lane-role-secrets-73622667.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Changed

- The intake, pr-unblock and pr-verify lanes run on their own funding roles' secrets instead of the `automations` pair. `intake-triage.yml` passes `role: intake` with `INTAKE_CLAUDE_API_KEY` and `INTAKE_CLAUDE_CODE_OAUTH_TOKEN`, and its intake reusable pins ci-workflows [`b0ecc27`](https://github.com/melodic-software/ci-workflows/commit/b0ecc273abe492d12f2efa98ee4741a84c219ed2), whose runner-policy contract names that role ([standards#863](https://github.com/melodic-software/standards/pull/863)). Every Claude job in `pr-unblock.yml` and the fix-docs jobs in `pr-refine.yml` pass `role: pr-unblock` with `PR_UNBLOCK_*`, and both `pr-verify.yml` jobs pass `role: pr-verify` with `PR_VERIFY_*`. Each run's `credential=` now names its own role's secret. `.github/lane-funding.json` lists the new secrets, the read-run secret allowlist in `scripts/check-read-caller-keys.mjs` admits `PR_UNBLOCK_*` in place of `AUTOMATIONS_*`, and `callers.test.mjs` fails when a catalog lane reads another role's secret or passes a `role:` other than its own ([pr-pipeline README, Funding](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#funding)).
38 changes: 30 additions & 8 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ test("intake-triage.yml: a thin issue's refinement runs its model with no write
assert.match(prompt, /denied="Bash,Edit,Write,NotebookEdit,WebSearch,Agent,Skill"/);
assert.match(prompt, /mcp__github,mcp__github_comment,mcp__github_file_ops,mcp__github_ci,mcp__github_inline_comment/);
assert.match(prompt, /WebFetch\(domain:\$domain\)/);
assert.deepEqual(stepSecrets(model), ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"]);
assert.deepEqual(stepSecrets(model), ["INTAKE_CLAUDE_CODE_OAUTH_TOKEN"]);
for (const job of [select, apply, again]) {
assert.deepEqual(stepSecrets(job), []);
}
Expand Down Expand Up @@ -1252,6 +1252,28 @@ test("every job that reads a Claude secret is a catalog lane, and every catalog
}
});

// run-claude-cli names the lane record's credential from `role:`, never from
// the secret passed, so the role and the secret are checked together.
test("every catalog lane reads only its role's secrets, and every role: it passes is its role", () => {
const jobs = claudeSecretJobs();
for (const { role, lane, key } of catalogLanes()) {
for (const name of lane.currentSecrets) assert.ok([role.keySecret, role.tokenSecret].includes(name), `${key} reads ${name}`);
const { job } = jobs.get(key);
const roles = [job.with?.role, ...(job.steps ?? []).map((step) => step.with?.role)].filter((value) => value !== undefined);
for (const value of roles) assert.equal(value, role.id, key);
}
// A runner caller that reads no Claude secret still names the credential
// the lane record reports, so it passes its workflow's role.
const workflowRoles = new Map();
for (const { role, lane } of catalogLanes()) workflowRoles.set(lane.workflow, [...new Set([...(workflowRoles.get(lane.workflow) ?? []), role.id])]);
for (const [file, ids] of workflowRoles) {
if (ids.length !== 1) continue;
for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) {
if (job?.uses === READ_RUNNER || job?.uses === "./.github/workflows/pr-run-activity-write.yml") assert.equal(job.with?.role, ids[0], `${file} ${id}`);
}
}
});

test("a job reads a Claude API key only when its catalog lane runs on run-claude-cli", () => {
const lanes = new Map(catalogLanes().map(({ lane, key }) => [key, lane]));
const keyed = [...claudeSecretJobs()].filter(([, { names }]) => names.some(isApiKey));
Expand Down Expand Up @@ -1622,14 +1644,14 @@ test("pr-run-activity-read.yml: the fallback call's lane record names why it ran
const lane = (fallback) =>
atStep(
record.with.lane.replace(/format\('([^']*)', inputs\.lane, inputs\.activity(?:, steps\.launch\.outputs\.key-name)?\)/g, (_, text) =>
JSON.stringify(text.replace("{0}", "pr-unblock").replace("{1}", "fix-ci").replace("{2}", "AUTOMATIONS_CLAUDE_API_KEY")),
JSON.stringify(text.replace("{0}", "pr-unblock").replace("{1}", "fix-ci").replace("{2}", "PR_UNBLOCK_CLAUDE_API_KEY")),
),
{ "inputs.fallback": fallback },
);
assert.equal(lane(""), "pr-unblock/fix-ci");
assert.equal(lane("credit-stop"), "pr-unblock/fix-ci (key AUTOMATIONS_CLAUDE_API_KEY hit a credit stop)");
assert.equal(lane("no-key"), "pr-unblock/fix-ci (key AUTOMATIONS_CLAUDE_API_KEY is empty)");
assert.equal(lane("handoff"), "pr-unblock/fix-ci (key AUTOMATIONS_CLAUDE_API_KEY posted no check)");
assert.equal(lane("credit-stop"), "pr-unblock/fix-ci (key PR_UNBLOCK_CLAUDE_API_KEY hit a credit stop)");
assert.equal(lane("no-key"), "pr-unblock/fix-ci (key PR_UNBLOCK_CLAUDE_API_KEY is empty)");
assert.equal(lane("handoff"), "pr-unblock/fix-ci (key PR_UNBLOCK_CLAUDE_API_KEY posted no check)");
assert.equal(record.with["step-timeout-minutes"], 50, "the launcher step's timeout");
});

Expand Down Expand Up @@ -1743,11 +1765,11 @@ for (const [file, firstId, subId, extraNeeds, extraProceed, applyId] of PATCH_LA
assert.equal(first.uses, READ_RUNNER);
assert.equal(sub.uses, READ_RUNNER);
assert.deepEqual([sub.needs].flat(), [...extraNeeds, firstId, ...(HANDOFF[firstId] ? [HANDOFF[firstId]] : [])]);
assert.deepEqual(first.secrets, { "claude-api-key": "${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}", ...CARRIER_SECRETS });
assert.deepEqual(sub.secrets, { "claude-code-oauth-token": "${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}", ...CARRIER_SECRETS });
assert.deepEqual(first.secrets, { "claude-api-key": "${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }}", ...CARRIER_SECRETS });
assert.deepEqual(sub.secrets, { "claude-code-oauth-token": "${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }}", ...CARRIER_SECRETS });
// The report job posts and labels through the caller's grant.
for (const job of [first, sub]) assert.equal(job.permissions.issues, "write");
assert.equal(first.with.role, "automations");
assert.equal(first.with.role, "pr-unblock");
assert.equal(first.with.fallback, undefined);
const { fallback, ...same } = sub.with;
assert.deepEqual(same, first.with, "the fallback call runs the same activity on the same inputs");
Expand Down
32 changes: 16 additions & 16 deletions .github/lane-funding.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
"keyed": true,
"category": "intake",
"lanes": [
{ "id": "intake-triage", "workflow": "intake-triage.yml", "launcher": "run-claude-cli", "triggers": ["issues", "workflow_dispatch"], "gate": "author", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY", "AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "refine-issue", "workflow": "intake-triage.yml", "launcher": "claude-code-action", "triggers": ["issues", "workflow_dispatch"], "gate": "author", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }
{ "id": "intake-triage", "workflow": "intake-triage.yml", "launcher": "run-claude-cli", "triggers": ["issues", "workflow_dispatch"], "gate": "author", "currentSecrets": ["INTAKE_CLAUDE_API_KEY", "INTAKE_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "refine-issue", "workflow": "intake-triage.yml", "launcher": "claude-code-action", "triggers": ["issues", "workflow_dispatch"], "gate": "author", "currentSecrets": ["INTAKE_CLAUDE_CODE_OAUTH_TOKEN"] }
]
},
{
Expand All @@ -45,18 +45,18 @@
"keyed": true,
"category": "pr-pipeline",
"lanes": [
{ "id": "fix-ci", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "fix-ci-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "fix-ci-rung-up", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "fix-ci-rung-up-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "fix-docs", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "fix-docs-subscription", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "address-feedback", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "address-feedback-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "review-dependency", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "review-dependency-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "resolve-conflicts", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "resolve-conflicts-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }
{ "id": "fix-ci", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "fix-ci-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "fix-ci-rung-up", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "fix-ci-rung-up-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "fix-docs", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "fix-docs-subscription", "workflow": "pr-refine.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "address-feedback", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "address-feedback-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "review-dependency", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "review-dependency-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] },
{ "id": "resolve-conflicts", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_API_KEY"] },
{ "id": "resolve-conflicts-subscription", "workflow": "pr-unblock.yml", "launcher": "run-claude-cli", "triggers": ["workflow_dispatch"], "currentSecrets": ["PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN"] }
]
},
{
Expand All @@ -67,8 +67,8 @@
"keyed": true,
"category": "pr-pipeline",
"lanes": [
{ "id": "check-intent", "workflow": "pr-verify.yml", "launcher": "run-claude-cli", "triggers": ["workflow_run"], "gate": "same-repo", "currentSecrets": ["AUTOMATIONS_CLAUDE_API_KEY"] },
{ "id": "check-intent-subscription", "workflow": "pr-verify.yml", "launcher": "run-claude-cli", "triggers": ["workflow_run"], "gate": "same-repo", "currentSecrets": ["AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"] }
{ "id": "check-intent", "workflow": "pr-verify.yml", "launcher": "run-claude-cli", "triggers": ["workflow_run"], "gate": "same-repo", "currentSecrets": ["PR_VERIFY_CLAUDE_API_KEY"] },
{ "id": "check-intent-subscription", "workflow": "pr-verify.yml", "launcher": "run-claude-cli", "triggers": ["workflow_run"], "gate": "same-repo", "currentSecrets": ["PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN"] }
]
},
{
Expand Down
18 changes: 9 additions & 9 deletions .github/scripts/pr-verify/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ const KEY_CLASS = "${{ steps.judge.outputs.lane-class == 'credit-stop' && 'rate-
const KEY_REPORT_IF =
"(steps.judge.outcome == 'success' || steps.judge.outcome == 'failure') && (steps.judge.outputs.handoff != 'true' || steps.key-leg.outcome == 'failure')";
const CREDENTIAL = {
[KEY]: ["api-key", "AUTOMATIONS_CLAUDE_API_KEY"],
[SUB]: ["subscription-token", "AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN"],
[KEY]: ["api-key", "PR_VERIFY_CLAUDE_API_KEY"],
[SUB]: ["subscription-token", "PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN"],
};

test("the upload is each job's only artifact step and follows the written verdict", () => {
Expand Down Expand Up @@ -88,18 +88,18 @@ test("the concurrency group is per PR and head and never cancels a running judgm
});

// The judgment on ci-workflows' run-claude-cli (docs/conventions/pr-pipeline/README.md,
// Funding: pr-verify takes the automations role), under the launcher's input
// Funding: pr-verify takes the pr-verify role), under the launcher's input
// contract (its action.yml: managed settings while sudo works, then runner
// isolation) and the deny rules that keep the model on its data files.
test("each job judges on run-claude-cli as the automations role with its one secret", () => {
test("each job judges on run-claude-cli as the pr-verify role with its one secret", () => {
const pins = new Set();
for (const job of JOBS) {
const judge = step("Judge the pull request", job);
const uses = /^ {8}uses: (.*)$/m.exec(judge)[1];
assert.equal(PIN.exec(uses)?.[1], "run-claude-cli", job);
pins.add(uses);
assert.equal(input(judge, "lane"), "pr-verify", job);
assert.equal(input(judge, "role"), "automations", job);
assert.equal(input(judge, "role"), "pr-verify", job);
const [own, secret] = CREDENTIAL[job];
const [other] = CREDENTIAL[job === KEY ? SUB : KEY];
assert.equal(input(judge, own), `\${{ secrets.${secret} }}`, job);
Expand Down Expand Up @@ -127,7 +127,7 @@ test("each secret appears only in its own job", () => {
assert.ok(jobText(job).includes(`secrets.${own}`), job);
assert.ok(!jobText(job).includes(`secrets.${other}`), job);
}
assert.doesNotMatch(text, /secrets\.(?!AUTOMATIONS_CLAUDE_API_KEY\b|AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN\b)/);
assert.doesNotMatch(text, /secrets\.(?!PR_VERIFY_CLAUDE_API_KEY\b|PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN\b)/);
assert.doesNotMatch(text, /secrets: inherit|toJSON\(secrets\)/);
});

Expand Down Expand Up @@ -193,8 +193,8 @@ test("the key job hands the judgment on only after a clean handoff or with no ke
// reaches the script through env, never interpolated.
const run = /^ {8}run: \|\n((?: {10}.*\n?)+)/m.exec(leg)[1];
assert.equal(run.split("\n")[0], ' case "$LANE_CLASS" in');
assert.match(run, /^ {12}auth\) echo "::warning::the API refused the automations role's API key \(AUTOMATIONS_CLAUDE_API_KEY\), class auth; the owner must replace the secret" ;;$/m);
assert.match(run, /^ {12}\*\) echo "::warning::the automations role's API key \(AUTOMATIONS_CLAUDE_API_KEY\) hit a credit stop; rotate a spare key in" ;;$/m);
assert.match(run, /^ {12}auth\) echo "::warning::the API refused the pr-verify role's API key \(PR_VERIFY_CLAUDE_API_KEY\), class auth; the owner must replace the secret" ;;$/m);
assert.match(run, /^ {12}\*\) echo "::warning::the pr-verify role's API key \(PR_VERIFY_CLAUDE_API_KEY\) hit a credit stop; rotate a spare key in" ;;$/m);
assert.doesNotMatch(run, /\$\{\{/);
assert.ok(run.lastIndexOf("::warning::") < run.indexOf("check-key-leg"));
assert.equal(index("Check the key leg", SUB), -1);
Expand All @@ -217,7 +217,7 @@ test("the subscription job repeats the key job's steps but for the credential",
.split("\n")
.filter((line) => line.trim() !== "" && !line.trim().startsWith("#"))
.join("\n")
.replace(/AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN/g, "AUTOMATIONS_CLAUDE_API_KEY")
.replace(/PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN/g, "PR_VERIFY_CLAUDE_API_KEY")
.replace(/^( {10})subscription-token: /m, "$1api-key: ")
.replace(`if: ${KEY_REPORT_IF}`, "if: steps.judge.outcome == 'success' || steps.judge.outcome == 'failure'")
.replace(`known-failure-class: ${KEY_CLASS}`, "known-failure-class: ${{ steps.judge.outputs.lane-class }}")
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/intake-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,10 @@ 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@d02b8697e092705153f0996549b05f95a446bb52 # v0.65.0
uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@b0ecc273abe492d12f2efa98ee4741a84c219ed2 # b0ecc27 2026-10-23 v0.65.0 plus a docs-only commit
with:
runner: ubuntu-24.04
role: automations
role: intake
claude-args: ${{ needs.born-triaged.outputs.claude-args }}
issue-number: ${{ inputs.issue-number }}
allowed-author-ids: ${{ needs.born-triaged.outputs.human-ids }}
Expand Down Expand Up @@ -290,8 +290,8 @@ jobs:
label, quote the decision, say in one sentence that it settles the open questions, and ask
nothing again. Triage the rest as usual.
secrets:
CLAUDE_API_KEY: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}
CLAUDE_API_KEY: ${{ secrets.INTAKE_CLAUDE_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.INTAKE_CLAUDE_CODE_OAUTH_TOKEN }}

# Writes the triage run's verdict on the issue as the lanes App, so the work
# lanes count it: the work-class label, the Size, Complexity and Uncertainty
Expand Down Expand Up @@ -653,7 +653,7 @@ jobs:
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1"
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
with:
claude_code_oauth_token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}
claude_code_oauth_token: ${{ secrets.INTAKE_CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ github.token }}
# A dispatch (intake-reenter.yml, the triage sweep) runs as
# github-actions[bot]; refine-select admitted the run's actor.
Expand Down
Loading
Loading