From 488f8a0eb27cce84e4df4107dfd25a4432f14694 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:30:55 -0400 Subject: [PATCH 1/3] feat(pr-pipeline): fund intake, pr-unblock and pr-verify from their own role secrets intake-triage passes role intake with INTAKE_*, on the intake reusable at ci-workflows b0ecc27 (registered on that role by standards#863). pr-unblock and pr-refine's fix-docs pass role pr-unblock with PR_UNBLOCK_*; pr-verify passes role pr-verify with PR_VERIFY_*. The catalog, the read-run secret allowlist and the docs follow, and callers.test.mjs checks each catalog lane's secrets and role: together. Co-Authored-By: Claude Opus 5.5 --- .changes/pr-pipeline/lane-role-secrets.md | 7 ++ .../actions/resolve-config/callers.test.mjs | 28 +++++--- .github/lane-funding.json | 32 +++++----- .github/scripts/pr-verify/workflow.test.mjs | 18 +++--- .github/workflows/intake-triage.yml | 10 +-- .github/workflows/pr-refine.yml | 12 ++-- .github/workflows/pr-unblock.yml | 64 +++++++++---------- .github/workflows/pr-verify.yml | 22 +++---- docs/conventions/pr-pipeline/README.md | 13 ++-- .../pr-pipeline/pr-run-activity.md | 10 +-- scripts/check-read-caller-keys.mjs | 12 ++-- scripts/check-read-caller-keys.sh | 4 +- scripts/check-read-caller-keys.test.sh | 12 ++-- 13 files changed, 133 insertions(+), 111 deletions(-) create mode 100644 .changes/pr-pipeline/lane-role-secrets.md diff --git a/.changes/pr-pipeline/lane-role-secrets.md b/.changes/pr-pipeline/lane-role-secrets.md new file mode 100644 index 0000000000..a5fce099e1 --- /dev/null +++ b/.changes/pr-pipeline/lane-role-secrets.md @@ -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)). diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 6eb203683b..ab4310c92d 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -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), []); } @@ -1252,6 +1252,18 @@ 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); + } +}); + 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)); @@ -1622,14 +1634,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"); }); @@ -1743,11 +1755,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"); diff --git a/.github/lane-funding.json b/.github/lane-funding.json index 0d60e0e8ed..ae997e6d39 100644 --- a/.github/lane-funding.json +++ b/.github/lane-funding.json @@ -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"] } ] }, { @@ -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"] } ] }, { @@ -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"] } ] }, { diff --git a/.github/scripts/pr-verify/workflow.test.mjs b/.github/scripts/pr-verify/workflow.test.mjs index bcc60d97de..64ab3b289d 100644 --- a/.github/scripts/pr-verify/workflow.test.mjs +++ b/.github/scripts/pr-verify/workflow.test.mjs @@ -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", () => { @@ -88,10 +88,10 @@ 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); @@ -99,7 +99,7 @@ test("each job judges on run-claude-cli as the automations role with its one sec 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); @@ -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\)/); }); @@ -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); @@ -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 }}") diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 319e1c5e28..eb143179b0 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -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 }} @@ -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 @@ -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. diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index dcf6738220..84165bf9d7 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -78,7 +78,7 @@ jobs: # fix-docs is four jobs, so no model ever holds a write token. The model # job: a read activity with no write token and no OIDC token in the job that # runs head code. The skill fixes the changed Markdown files; the runner - # uploads its uncommitted edits as a patch. It runs on the automations + # uploads its uncommitted edits as a patch. It runs on the pr-unblock # role's API key alone. Drafts and held PRs skip in the config's # applies-when, before any model turn. fix-docs: @@ -97,7 +97,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-refine activity: fix-docs pr-number: ${{ inputs.pr-number }} @@ -107,7 +107,7 @@ jobs: post-decline: false timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # Whether fix-docs's report job posted its check, and whether the broker @@ -171,7 +171,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-refine activity: fix-docs fallback: handoff @@ -182,7 +182,7 @@ jobs: post-decline: false timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # fix-docs's push job: the apply-patch activity under patch-rules.json's @@ -210,7 +210,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-refine activity: apply-patch pr-number: ${{ inputs.pr-number }} diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index c2b150f8c8..5adacf8698 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -271,7 +271,7 @@ jobs: # again, refuses any conflict tree but the select job's, and gives the # skill the conflicts as read-only files with no git or Bash; the skill # resolves them in place and the runner hands its edits on as a patch - # measured from the conflict tree. It runs on the automations role's API + # measured from the conflict tree. It runs on the pr-unblock role's API # key alone, in the per-PR write queue. resolve-conflicts: needs: resolve-conflicts-select @@ -288,7 +288,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: resolve-conflicts pr-number: ${{ inputs.pr-number }} @@ -300,7 +300,7 @@ jobs: # The 50-minute launcher step, the CLI install and the merge. timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # Whether resolve-conflicts's report job posted its check, read from this @@ -353,7 +353,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: resolve-conflicts fallback: handoff @@ -365,7 +365,7 @@ jobs: merge-conflict-tree: ${{ needs.resolve-conflicts-select.outputs.conflict-tree }} timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # The push job: apply-patch.mjs under patch-rules.json's merge-base row, a @@ -397,7 +397,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-conflict-patch pr-number: ${{ inputs.pr-number }} @@ -656,7 +656,7 @@ jobs: # the job that runs head code. The skill fixes the kept threads and records # a reply for each with the review-thread tool; the runner uploads its # uncommitted edits as a patch and, after its leak scan, its replies. It - # runs on the automations role's API key alone, in the per-PR write queue. + # runs on the pr-unblock role's API key alone, in the per-PR write queue. address-feedback: needs: address-feedback-select if: ${{ !cancelled() && needs.address-feedback-select.outputs.proceed == 'true' }} @@ -671,7 +671,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: address-feedback pr-number: ${{ inputs.pr-number }} @@ -683,7 +683,7 @@ jobs: # The 50-minute launcher step and the CLI install. timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # Whether address-feedback's report job posted its check, read from this run's @@ -736,7 +736,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: address-feedback fallback: handoff @@ -748,7 +748,7 @@ jobs: head-sha: ${{ needs.address-feedback-select.outputs.head-sha }} timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # The push job: apply-patch.mjs under patch-rules.json's review-threads @@ -780,7 +780,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-feedback-patch pr-number: ${{ inputs.pr-number }} @@ -821,7 +821,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: publish-feedback pr-number: ${{ inputs.pr-number }} @@ -1024,7 +1024,7 @@ jobs: # the job that runs head code. The skill reviews the update and returns its # verdict as structured output; on a fix it leaves the fix uncommitted, and # the runner hands it on as a patch. Its upstream release notes are - # untrusted text. It runs on the automations role's API key alone, in the + # untrusted text. It runs on the pr-unblock role's API key alone, in the # per-PR write queue. review-dependency: needs: review-dependency-select @@ -1040,7 +1040,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: review-dependency pr-number: ${{ inputs.pr-number }} @@ -1052,7 +1052,7 @@ jobs: # The 50-minute launcher step and the CLI install. timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # Whether review-dependency's report job posted its check, read from this run's @@ -1105,7 +1105,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: review-dependency fallback: handoff @@ -1117,7 +1117,7 @@ jobs: head-sha: ${{ needs.review-dependency-select.outputs.head-sha }} timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # The push job: apply-patch.mjs under patch-rules.json's dependency-review @@ -1150,7 +1150,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-dependency-patch pr-number: ${{ inputs.pr-number }} @@ -1189,7 +1189,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-dependency-verdict pr-number: ${{ inputs.pr-number }} @@ -1330,7 +1330,7 @@ jobs: # The model job: a read activity with no write token and no OIDC token in # the job that runs head code. The skill reruns the failing checks, fixes # them and reruns them; the runner uploads its uncommitted edits as a patch. - # It runs on the automations role's API key alone. The per-PR write queue + # It runs on the pr-unblock role's API key alone. The per-PR write queue # holds it, never the run's own group, pr-unblock-fix-ci-: a job group # with that name waits on its own run and never starts. fix-ci: @@ -1347,7 +1347,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: fix-ci pr-number: ${{ inputs.pr-number }} @@ -1359,7 +1359,7 @@ jobs: # Reproducing, fixing and rerunning several checks outlasts the default. timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # Whether fix-ci's report job posted its check, read from this run's jobs @@ -1417,7 +1417,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: fix-ci fallback: handoff @@ -1429,7 +1429,7 @@ jobs: head-sha: ${{ needs.fix-ci-context.outputs.head-sha }} timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # The push job: apply-patch.mjs, a script with no model that runs no head @@ -1459,7 +1459,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-patch pr-number: ${{ inputs.pr-number }} @@ -1502,7 +1502,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: fix-ci pr-number: ${{ inputs.pr-number }} @@ -1514,7 +1514,7 @@ jobs: model-rung: "1" timeout-minutes: 60 secrets: - claude-api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + claude-api-key: ${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} # As fix-ci-handoff and fix-ci-subscription are for fix-ci. @@ -1564,7 +1564,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-read.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: fix-ci fallback: handoff @@ -1577,7 +1577,7 @@ jobs: model-rung: "1" timeout-minutes: 60 secrets: - claude-code-oauth-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + claude-code-oauth-token: ${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }} scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} apply-patch-rung-up: @@ -1598,7 +1598,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: apply-patch pr-number: ${{ inputs.pr-number }} @@ -1760,7 +1760,7 @@ jobs: queue: max uses: ./.github/workflows/pr-run-activity-write.yml # zizmor: ignore[self-repository] with: - role: automations + role: pr-unblock lane: pr-unblock activity: report-escalation pr-number: ${{ inputs.pr-number }} diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index aaa5d296ac..696c880950 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -17,7 +17,7 @@ name: pr-verify # issues are data files in pr-verify-data under the working directory, and the # model reads them with the Read tool alone: its rules allow that directory # and deny .base and every other tool. The judgment runs on ci-workflows' -# run-claude-cli as the automations role, on one credential per job. The +# run-claude-cli as the pr-verify role, on one credential per job. The # check-intent job holds only the API key. The check-intent-subscription job # holds only the subscription token and runs, on a new ubuntu-24.04 VM, only # when check-intent's key leg handed off (run-claude-cli's handoff output: @@ -195,8 +195,8 @@ jobs: id: credential if: steps.agree.outcome == 'success' env: - PRESENT: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY != '' }} - SECRET_NAME: AUTOMATIONS_CLAUDE_API_KEY + PRESENT: ${{ secrets.PR_VERIFY_CLAUDE_API_KEY != '' }} + SECRET_NAME: PR_VERIFY_CLAUDE_API_KEY run: | if [ "$PRESENT" = true ]; then echo "present=true" >>"$GITHUB_OUTPUT" @@ -265,12 +265,12 @@ jobs: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" with: lane: pr-verify - role: automations + role: pr-verify prompt-file: ${{ runner.temp }}/pr-verify-prompt.md preamble: >- You run unattended; return only the verdict the schema asks for; nothing you write is posted. - api-key: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} + api-key: ${{ secrets.PR_VERIFY_CLAUDE_API_KEY }} model: ${{ steps.models.outputs.model }} effort: ${{ steps.models.outputs.effort }} max-turns: ${{ steps.models.outputs.max-turns }} @@ -306,8 +306,8 @@ jobs: LANE_CLASS: ${{ steps.judge.outputs.lane-class }} run: | case "$LANE_CLASS" in - auth) echo "::warning::the API refused the automations role's API key (AUTOMATIONS_CLAUDE_API_KEY), class auth; the owner must replace the secret" ;; - *) echo "::warning::the automations role's API key (AUTOMATIONS_CLAUDE_API_KEY) hit a credit stop; rotate a spare key in" ;; + 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" ;; + *) echo "::warning::the pr-verify role's API key (PR_VERIFY_CLAUDE_API_KEY) hit a credit stop; rotate a spare key in" ;; esac node .base/.github/scripts/pr-verify/write-verdict.mjs check-key-leg echo "::notice::check-intent-subscription judges on the subscription token" @@ -519,8 +519,8 @@ jobs: id: credential if: steps.agree.outcome == 'success' env: - PRESENT: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN != '' }} - SECRET_NAME: AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN + PRESENT: ${{ secrets.PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN != '' }} + SECRET_NAME: PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN run: | if [ "$PRESENT" = true ]; then echo "present=true" >>"$GITHUB_OUTPUT" @@ -587,12 +587,12 @@ jobs: CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" with: lane: pr-verify - role: automations + role: pr-verify prompt-file: ${{ runner.temp }}/pr-verify-prompt.md preamble: >- You run unattended; return only the verdict the schema asks for; nothing you write is posted. - subscription-token: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} + subscription-token: ${{ secrets.PR_VERIFY_CLAUDE_CODE_OAUTH_TOKEN }} model: ${{ steps.models.outputs.model }} effort: ${{ steps.models.outputs.effort }} max-turns: ${{ steps.models.outputs.max-turns }} diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 831d497999..bdef4609b5 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -260,7 +260,7 @@ never merges; the pr-merge lane merges on its `verdict=merge` marker at the curr `fix-ci-context` (base code only, read-only token) writes the failing checks' context or hands the pull request to a person once `loop.fix-ci-rounds` fixes were pushed; `fix-ci` (a `read` skill in [patch output mode](pr-run-activity.md#patch-output-mode), on run-claude-cli with the -automations role's API key) runs `source-control:pull-request ci --ci`, which reruns, fixes and +pr-unblock role's API key) runs `source-control:pull-request ci --ci`, which reruns, fixes and reruns the checks; `fix-ci-subscription` runs the same on the role's OAuth token, only when `fix-ci-handoff`'s read of this run's jobs shows `fix-ci` posted no check, as a credit stop or an empty key leaves it @@ -296,7 +296,7 @@ lowest number first. Their run ids join the dispatched-runs record. The dispatch concurrency group cancels a run in progress, so a burst of pushes ends in one listing. `pr-refine`'s `fix-docs` runs the same way in three jobs of `pr-refine.yml`: `fix-docs` (a -`read` skill in patch output mode with no prepare mode, on run-claude-cli with the automations +`read` skill in patch output mode with no prepare mode, on run-claude-cli with the pr-unblock role's API key) runs `ai-slop:audit fix` on the PR's changed Markdown files; `fix-docs-subscription` runs the same on the role's OAuth token, only when `fix-docs-handoff`'s read of this run's jobs shows the broker allowed `fix-docs` and `fix-docs` posted no check, as a @@ -1364,7 +1364,7 @@ work, or claims the whole issue, fails. The body only narrows what is judged, ne requirement, and the spec trust rules below are unchanged. resolve-pr marks each spec entry's `spec_kind` (`closes`, `refs` or `pull-request`), and the verdict records it, so the gate refuses a pass (`verify:spec-changed`) when the issue is now linked the other way. The model runs -on ci-workflows' `run-claude-cli` as the `automations` role ([Funding](#funding)), with the Read +on ci-workflows' `run-claude-cli` as the `pr-verify` role ([Funding](#funding)), with the Read tool alone, allowed only on the data files in the working directory's `pr-verify-data`. The run uploads one artifact, `pr-verify---`, holding the verdict JSON. Every reader also accepts that name followed by `-`, the first 16 hex digits of the SHA-256 @@ -1883,10 +1883,9 @@ the pipeline. A role's secrets are `_CLAUDE_API_KEY` and [`.github/lane-funding.json`](../../../.github/lane-funding.json) is the source of truth: each role's secrets, whether it is keyed, and each lane's workflow, job, launcher, triggers, gate, and -the secrets its job reads today, which can still be another role's or the bare -`CLAUDE_CODE_OAUTH_TOKEN` until its wiring moves. `callers.test.mjs` in -`.github/actions/resolve-config/` fails when a job that reads a Claude secret is missing from the -catalog, or reads secrets other than those listed, and when a job that holds one under +the secrets its job reads. `callers.test.mjs` in `.github/actions/resolve-config/` fails when a +job that reads a Claude secret is missing from the catalog, reads secrets other than those +listed or another role's, or passes a `role:` other than its own, and when a job that holds one under `pull_request_target`, `issues` or `workflow_run` shows no author gate (check-trusted-trigger in a job it needs, or the intake reusable's `allowed-author-ids`) or same-repo gate. diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index eae787bdcf..68fa4fac39 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -645,9 +645,9 @@ Read file, PR mode: a skill runs on ci-workflows' [`run-claude-cli`](https://github.com/melodic-software/ci-workflows/blob/7b362428c76085a160e28656cd2c75c3147a69ff/README.md) (v0.54.0), which takes exactly one credential and falls back to the subscription only as a second job. The caller calls the read file twice, each call with one secret, named for its `role`: -`pr-unblock.yml`'s `fix-ci` passes only `claude-api-key` (`AUTOMATIONS_CLAUDE_API_KEY`), and +`pr-unblock.yml`'s `fix-ci` passes only `claude-api-key` (`PR_UNBLOCK_CLAUDE_API_KEY`), and `fix-ci-subscription` passes only `claude-code-oauth-token` -(`AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN`); its `fix-docs`, `address-feedback` and +(`PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN`); its `fix-docs`, `address-feedback` and `review-dependency` calls and their `-subscription` calls do the same. Both queue in `pr-pipeline-write-`. Each fallback call starts on no first-call output: a handoff job (`fix-ci-handoff`, `fix-ci-rung-up-handoff`, `address-feedback-handoff`, @@ -1165,7 +1165,7 @@ warns of a [classifier gap](README.md#classifier-gaps). pr-unblock's `fix-ci` fixes a lanes App or [adopted](README.md#adoption) owner pull request's failing checks in separate jobs, so no model -holds a write token: a base job, a read model job in this mode on the automations role's API key, +holds a write token: a base job, a read model job in this mode on the pr-unblock role's API key, its fallback call on the role's OAuth token after a credit stop or with the key empty ([Two calls on one credential each](#two-calls-on-one-credential-each)), and an [`apply-patch`](#prepare-apply-patch) script job. @@ -1530,8 +1530,8 @@ form, any case) and down through every reusable workflow those runs call. It fai such a run names `AUTOMATION_LANES_APP_PRIVATE_KEY` or `app-private-key` in a key or value, has a `private-key` key, passes `secrets: inherit`, or has a `${{ }}` expression, in a key, a value or a `#` line of a `run:` block, whose `secrets` reference is not `secrets.` with `` on the allowlist -`claude_code_oauth_token`, `automations_claude_code_oauth_token` (the lanes' Claude Code OAuth -token), `github_token` (case-insensitive, `-` read as `_`); `toJSON(secrets)`, +`claude_code_oauth_token`, `pr_unblock_claude_code_oauth_token`, +`issue_lane_claude_code_oauth_token`, `claude_api_key`, `pr_unblock_claude_api_key`, `github_token` (case-insensitive, `-` read as `_`); `toJSON(secrets)`, `secrets[...]` and bare `secrets` fail. It also fails when any file of such a run sets `permissions: write-all`, and, in the read file, when the top level is not `permissions: {}`, a job has no permissions map, `id-token` is named anywhere outside the `broker-check` job, that job diff --git a/scripts/check-read-caller-keys.mjs b/scripts/check-read-caller-keys.mjs index a4d9b989d6..9ed18f08b7 100644 --- a/scripts/check-read-caller-keys.mjs +++ b/scripts/check-read-caller-keys.mjs @@ -24,17 +24,17 @@ const WORKFLOW_USES = new RegExp( const APP_KEY = /automation_lanes_app_private_key|app-private-key/i; const APP_KEY_INPUTS = new Set(["app-private-key", "private-key"]); // Secrets a read run may name, compared lower-cased with `-` read as `_`. -// automations_claude_code_oauth_token is the lanes' Claude Code OAuth token, -// the same kind of credential as claude_code_oauth_token under the automation -// account's name; pr-unblock.yml's fix-ci-subscription read job runs on it. -// claude_api_key and automations_claude_api_key are the role's Claude Console +// pr_unblock_claude_code_oauth_token is the pr-unblock role's Claude Code OAuth +// token, the same kind of credential as claude_code_oauth_token under the +// role's name; pr-unblock.yml's fix-ci-subscription read job runs on it. +// claude_api_key and pr_unblock_claude_api_key are the role's Claude Console // API key, which fix-ci's first read job runs on. const ALLOWED_SECRETS = new Set([ "claude_code_oauth_token", - "automations_claude_code_oauth_token", + "pr_unblock_claude_code_oauth_token", "issue_lane_claude_code_oauth_token", "claude_api_key", - "automations_claude_api_key", + "pr_unblock_claude_api_key", "github_token", ]); const KEY = "references the App key"; diff --git a/scripts/check-read-caller-keys.sh b/scripts/check-read-caller-keys.sh index 305e16c39e..14fb4b22fa 100755 --- a/scripts/check-read-caller-keys.sh +++ b/scripts/check-read-caller-keys.sh @@ -20,9 +20,9 @@ # - no `secrets: inherit`; # - every `secrets` reference in a `${{ }}` expression is `secrets.` # with `` on the allowlist `claude_code_oauth_token`, -# `automations_claude_code_oauth_token`, +# `pr_unblock_claude_code_oauth_token`, # `issue_lane_claude_code_oauth_token`, `claude_api_key`, -# `automations_claude_api_key`, `github_token` +# `pr_unblock_claude_api_key`, `github_token` # (lower-cased, `-` read as `_`); `toJSON(secrets)`, `secrets[...]` and # bare `secrets` fail; # - one exception to both rules above, the scan-carrier key: the read diff --git a/scripts/check-read-caller-keys.test.sh b/scripts/check-read-caller-keys.test.sh index 1bfee35c87..f98c6cf0b8 100755 --- a/scripts/check-read-caller-keys.test.sh +++ b/scripts/check-read-caller-keys.test.sh @@ -168,11 +168,15 @@ run_case "a read caller passing a secret off the allowlist fails" 1 \ "printf ' other: \${{ secrets.DEPLOY_TOKEN }}\n' >>$CALLER" \ "$CALLER:11: reads secrets.DEPLOY_TOKEN, which is not on the read-run allowlist" -run_case "a read caller passing the lanes' Claude Code OAuth token passes" 0 \ - "printf ' other: \${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}\n' >>$CALLER" +run_case "a read caller passing the pr-unblock role's Claude Code OAuth token passes" 0 \ + "printf ' other: \${{ secrets.PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN }}\n' >>$CALLER" -run_case "a read caller passing the automations role's Claude API key passes" 0 \ - "printf ' claude-api-key: \${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}\n' >>$CALLER" +run_case "a read caller passing the pr-unblock role's Claude API key passes" 0 \ + "printf ' claude-api-key: \${{ secrets.PR_UNBLOCK_CLAUDE_API_KEY }}\n' >>$CALLER" + +run_case "a read caller passing the automations role's Claude API key fails" 1 \ + "printf ' claude-api-key: \${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}\n' >>$CALLER" \ + "$CALLER:11: reads secrets.AUTOMATIONS_CLAUDE_API_KEY, which is not on the read-run allowlist" run_case "a read caller passing another role's Claude API key fails" 1 \ "printf ' claude-api-key: \${{ secrets.ISSUE_LANE_CLAUDE_API_KEY }}\n' >>$CALLER" \ From dd0dbc72af676217a4b641ac6b8ef8aa473468c4 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:34:17 -0400 Subject: [PATCH 2/3] chore(pr-pipeline): name the changelog fragment by branch slug Co-Authored-By: Claude Opus 5.5 --- .../{lane-role-secrets.md => feat-lane-role-secrets-73622667.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changes/pr-pipeline/{lane-role-secrets.md => feat-lane-role-secrets-73622667.md} (100%) diff --git a/.changes/pr-pipeline/lane-role-secrets.md b/.changes/pr-pipeline/feat-lane-role-secrets-73622667.md similarity index 100% rename from .changes/pr-pipeline/lane-role-secrets.md rename to .changes/pr-pipeline/feat-lane-role-secrets-73622667.md From bb74d9868389ea7ec06a83e76f9fccd460bbe785 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:41 -0400 Subject: [PATCH 3/3] fix(pr-pipeline): check the runner callers' role and name fix-docs' workflow Every pr-run-activity read or write caller in a single-role catalog workflow must pass that role, so a write caller that reads no Claude secret cannot keep a stale role label. pr-run-activity.md credits fix-docs to pr-refine.yml, and ADR 0074 names the pr-unblock role. Co-Authored-By: Claude Opus 5.5 --- .github/actions/resolve-config/callers.test.mjs | 10 ++++++++++ ...updates-in-a-model-job-and-act-in-a-scripted-job.md | 2 +- docs/conventions/pr-pipeline/pr-run-activity.md | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index ab4310c92d..8cfc9d75a2 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -1262,6 +1262,16 @@ test("every catalog lane reads only its role's secrets, and every role: it passe 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", () => { diff --git a/docs/adr/0074-review-risky-dependabot-updates-in-a-model-job-and-act-in-a-scripted-job.md b/docs/adr/0074-review-risky-dependabot-updates-in-a-model-job-and-act-in-a-scripted-job.md index dded990cdc..996512c4c3 100644 --- a/docs/adr/0074-review-risky-dependabot-updates-in-a-model-job-and-act-in-a-scripted-job.md +++ b/docs/adr/0074-review-risky-dependabot-updates-in-a-model-job-and-act-in-a-scripted-job.md @@ -113,7 +113,7 @@ model job no longer holds `contents: write`. - **The model job is a read skill.** `review-dependency` is an `effect: read` skill leaf with `applied-by` `{patch: apply-dependency-patch, dependency-verdict: apply-dependency-verdict}`, on - the read runner, on `run-claude-cli` (the automations role's API key, then its subscription + the read runner, on `run-claude-cli` (the pr-unblock role's API key, then its subscription token after a credit stop or with an empty key). Its tools are Read, Grep, Glob, Skill, Edit, Write and Agent, with no Bash, and WebFetch on the five configured hosts. It leaves a fix uncommitted; the runner hands it on as a patch, and its verdict, the launcher's structured diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 68fa4fac39..10c553a860 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -647,8 +647,8 @@ Read file, PR mode: a skill runs on ci-workflows' job. The caller calls the read file twice, each call with one secret, named for its `role`: `pr-unblock.yml`'s `fix-ci` passes only `claude-api-key` (`PR_UNBLOCK_CLAUDE_API_KEY`), and `fix-ci-subscription` passes only `claude-code-oauth-token` -(`PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN`); its `fix-docs`, `address-feedback` and -`review-dependency` calls and their `-subscription` calls do the same. Both queue in +(`PR_UNBLOCK_CLAUDE_CODE_OAUTH_TOKEN`); its `address-feedback` and `review-dependency` calls, +`pr-refine.yml`'s `fix-docs`, and their `-subscription` calls do the same. Both queue in `pr-pipeline-write-`. Each fallback call starts on no first-call output: a handoff job (`fix-ci-handoff`, `fix-ci-rung-up-handoff`, `address-feedback-handoff`, `review-dependency-handoff`, and `pr-refine.yml`'s `fix-docs-handoff`) with only `actions: read`