From 0bcc13dface6be3dd7805ab1f7ecdb26de638b6b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:36:31 -0400 Subject: [PATCH] feat(runner-policy): admit role-named secrets and register ci-workflows v0.51.0 intake A contract with allowedCallerPermissions still maps each secret to its own name, or now, when it declares a funding role, to that role's prefixed name (_NAME, role upper-snake). The role must be in allowedInputs and the caller must pass it as that exact literal; every other rename is refused. Role-named secrets let several funding roles share one repository. Registers intake-triage at the v0.51.0 tag f129f4c with role automations: the v0.49.0 entry plus the role input and the automations role's API key and subscription token. The v0.49.0 entry stays until the caller repins. Co-Authored-By: Claude Opus 5.5 --- components/runner-policy/README.md | 19 ++++ components/runner-policy/policy.json | 26 +++++ components/runner-policy/policy.schema.json | 1 + components/runner-policy/runner-policy.mjs | 26 ++++- .../runner-policy/runner-policy.test.mjs | 105 ++++++++++++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) diff --git a/components/runner-policy/README.md b/components/runner-policy/README.md index 7735a429..636776c3 100644 --- a/components/runner-policy/README.md +++ b/components/runner-policy/README.md @@ -224,6 +224,14 @@ unknown secret names, and alternate expressions: the ordinary explicit read-only requirement unless the contract also names `allowedCallerPermissions`, and every secret-capable runner-input contract is excluded from the Dependabot auto-approval extension below. + A contract with `allowedCallerPermissions` maps each secret to its own name + (`NAME: ${{ secrets.NAME }}`). A contract that also declares a funding + `role` (lower kebab, and `role` must be in `allowedInputs`) may instead map + it to that role's prefixed name, `NAME: ${{ secrets._NAME }}`, with + `` upper-snake (`issue-lane` is `ISSUE_LANE`), and its caller must pass + `role:` as that exact literal. Role-named secrets let several funding roles + share one repository; the role stays a reviewed literal, never an + expression, and every other rename is still refused. - `hosted-only` has no runner input. It records the GitHub-hosted labels found in the immutable called workflow and rejects any caller-added input that was not part of the review. @@ -1283,6 +1291,17 @@ inputs: `package-auto-merge` (boolean, default `true`), which also arms npm, pip and NuGet patch and minor bumps, and `review-only-dependencies` (a JSON array, default `[]`) of package names never armed. No secret, workflow or job permission, or `runs-on` routing changes. +`intake-triage` is registered at the v0.51.0 tag +`f129f4c142d00ec3feacbf2b9102d327088504bf` (ci-workflows#784), sign-off +pending. It copies its v0.49.0 entry, adds the `role` input and replaces the +one secret with the `automations` role's two: `CLAUDE_API_KEY` from +`AUTOMATIONS_CLAUDE_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` from +`AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN`. The lane now runs the CLI through the +run-claude-cli action, which refuses to start without root-owned managed +settings, removed sudo and docker access, and bubblewrap; the API key leg runs +first and the subscription leg only after a credit stop. The v0.49.0 entry +stays until claude-code-plugins repins its caller. No workflow or job +permission, or `runs-on` routing changes. The Zizmor contract at `de50a08b6093d231519ee7a4c9371db76c0a7e1e` uses its reviewed `runner` input and checksum-verified native Linux binary, so enrolled consumers may route that advisory lane onto the managed fleet diff --git a/components/runner-policy/policy.json b/components/runner-policy/policy.json index ce7ca6e5..8f46d3ff 100644 --- a/components/runner-policy/policy.json +++ b/components/runner-policy/policy.json @@ -99,6 +99,32 @@ "issues": "write" } }, + "melodic-software/ci-workflows/.github/workflows/intake-triage.yml@f129f4c142d00ec3feacbf2b9102d327088504bf": { + "routing": "runner-input", + "runnerInput": "runner", + "role": "automations", + "allowedInputs": [ + "runner", + "allowed-labels", + "escalation-label", + "floor-label", + "instructions", + "cli-version", + "claude-args", + "issue-number", + "allowed-author-ids", + "allowed-bot-sender-ids", + "role" + ], + "allowedSecrets": { + "CLAUDE_API_KEY": "${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}", + "CLAUDE_CODE_OAUTH_TOKEN": "${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}" + }, + "allowedCallerPermissions": { + "contents": "read", + "issues": "write" + } + }, "melodic-software/ci-workflows/.github/workflows/pr-review.yml@ab83b01273026ab5c23c6f3b40e946d97a863fa2": { "routing": "runner-input", "runnerInput": "runner", diff --git a/components/runner-policy/policy.schema.json b/components/runner-policy/policy.schema.json index 3adc6c17..a2787edb 100644 --- a/components/runner-policy/policy.schema.json +++ b/components/runner-policy/policy.schema.json @@ -132,6 +132,7 @@ "properties": { "routing": { "const": "runner-input" }, "runnerInput": { "$ref": "#/$defs/inputName" }, + "role": { "type": "string", "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" }, "allowedInputs": { "$ref": "#/$defs/uniqueStringArray" }, "allowedSecrets": { "$ref": "#/$defs/stringMap" }, "allowedCallerPermissions": { "$ref": "#/$defs/githubPermissionMap" }, diff --git a/components/runner-policy/runner-policy.mjs b/components/runner-policy/runner-policy.mjs index 78e7965e..105e36d3 100755 --- a/components/runner-policy/runner-policy.mjs +++ b/components/runner-policy/runner-policy.mjs @@ -231,12 +231,25 @@ export function validatePolicy(value) { ); } } + if (contract.role && !contract.allowedInputs.includes("role")) { + throw new ConfigurationError( + `reusable workflow contract ${reference}.allowedInputs must include role when role is declared`, + ); + } if (Object.hasOwn(contract, "allowedCallerPermissions")) { + // A secret maps to its own name, or, for a contract that declares a + // funding role, to that role's prefixed name: several roles can then + // share one repository, and the role is a reviewed literal. + const prefix = contract.role ? `${contract.role.toUpperCase().replaceAll("-", "_")}_` : ""; for (const [name, expression] of Object.entries(contract.allowedSecrets)) { const expected = `\${{ secrets.${name} }}`; - if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) || expression !== expected) { + const roleNamed = prefix ? `\${{ secrets.${prefix}${name} }}` : expected; + if ( + !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) || + (expression !== expected && expression !== roleNamed) + ) { throw new ConfigurationError( - `reusable workflow contract ${reference}.allowedSecrets.${name} must be exactly ${JSON.stringify(expected)} when allowedCallerPermissions is present`, + `reusable workflow contract ${reference}.allowedSecrets.${name} must be exactly ${JSON.stringify(expected)}${prefix ? ` or ${JSON.stringify(roleNamed)}` : ""} when allowedCallerPermissions is present`, ); } } @@ -267,6 +280,7 @@ export function validatePolicy(value) { approvedReusableWorkflowContracts.set(reference, { routing: contract.routing, ...(contract.runnerInput ? { runnerInput: contract.runnerInput } : {}), + ...(contract.role ? { role: contract.role } : {}), allowedInputs: new Set(contract.allowedInputs), allowedSecrets: contract.allowedSecrets, allowedSecretNames: new Set(Object.keys(contract.allowedSecrets)), @@ -960,6 +974,13 @@ function reusableWorkflowStatus(job, policy, workflow) { reason: `the reviewed reusable workflow must receive its ${contract.runnerInput} input explicitly`, }; } + if (contract.role && inputs.role !== contract.role) { + return { + isReusable: true, + approved: false, + reason: `the reviewed reusable workflow must receive role: ${contract.role} as a literal`, + }; + } if (contract.allowedCallerPermissions) { const permissionError = exactCanonicalMap( effectivePermissions(workflow, job), @@ -1863,6 +1884,7 @@ function reviewedContractSurface(contract) { return normalizeStructuralValue({ routing: contract.routing, ...(contract.runnerInput ? { runnerInput: contract.runnerInput } : {}), + ...(contract.role ? { role: contract.role } : {}), allowedInputs: [...contract.allowedInputs].sort((left, right) => left.localeCompare(right)), allowedSecrets: contract.allowedSecrets, ...(contract.fixedRunsOn diff --git a/components/runner-policy/runner-policy.test.mjs b/components/runner-policy/runner-policy.test.mjs index 2c89ec6a..718b6da8 100644 --- a/components/runner-policy/runner-policy.test.mjs +++ b/components/runner-policy/runner-policy.test.mjs @@ -47,6 +47,7 @@ const REPINE_LANE_SHA_V0_44_1 = "bc02e0504d83df70a2c95fcf3651d0f1eac3e252"; const REPINE_LANE_SHA_V0_45_0 = "3c8a4b2aeff5871cf72d35a686692bc6f0d44815"; const REPINE_LANE_SHA_V0_48_0 = "96b50d451c4483c476f2f963674e0a3ba9d50fe3"; const REPINE_LANE_SHA_V0_49_0 = "ff8cd36321423de961381e608e4a31563e74adb3"; +const REPINE_LANE_SHA_V0_51_0 = "f129f4c142d00ec3feacbf2b9102d327088504bf"; const STANDARDS_SYNC_SHA = "35f2684ac953794b854bac1959df00e74eeca1d9"; const REUSABLE_PATH = "melodic-software/ci-workflows/.github/workflows/osv-scanner.yml"; const REUSABLE_REFERENCE = `${REUSABLE_PATH}@${SHA}`; @@ -3760,6 +3761,88 @@ test("privileged reusable contracts accept only direct same-name secret mappings } }); +function roleContract(overrides = {}) { + return { + routing: "runner-input", + runnerInput: "runner", + role: "automations", + allowedInputs: ["runner", "role"], + allowedSecrets: { + CLAUDE_API_KEY: `\${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}`, + }, + allowedCallerPermissions: { contents: "read", issues: "write" }, + ...overrides, + }; +} + +function roleCaller(role, secret = "AUTOMATIONS_CLAUDE_API_KEY") { + return `permissions: read-all +jobs: + intake: + permissions: + contents: read + issues: write + uses: ${FLEET_CLAUDE_REVIEW_REFERENCE} + with: + runner: ${FLEET_LABEL} + role: ${role} + secrets: + CLAUDE_API_KEY: \${{ secrets.${secret} }} +`; +} + +test("a privileged contract that declares a role accepts that role's prefixed secret", async () => { + const root = await repository({ + policyOverrides: { + approvedReusableWorkflowContracts: { [FLEET_CLAUDE_REVIEW_REFERENCE]: roleContract() }, + }, + workflows: { "intake.yml": roleCaller("automations") }, + }); + assert.ok(!(await audit(root)).some(({ rule }) => rule === "runner-target-contract")); +}); + +test("a role-prefixed secret is refused for another role or with no declared role", async () => { + for (const contract of [ + roleContract({ + allowedSecrets: { CLAUDE_API_KEY: `\${{ secrets.REVIEWS_CLAUDE_API_KEY }}` }, + }), + roleContract({ role: undefined, allowedInputs: ["runner"] }), + ]) { + const root = await repository({ + policyOverrides: { + approvedReusableWorkflowContracts: { [FLEET_CLAUDE_REVIEW_REFERENCE]: contract }, + }, + }); + await assert.rejects(() => audit(root), /allowedSecrets\.CLAUDE_API_KEY must be exactly/); + } +}); + +test("a contract that declares a role must allow the role input", async () => { + const root = await repository({ + policyOverrides: { + approvedReusableWorkflowContracts: { + [FLEET_CLAUDE_REVIEW_REFERENCE]: roleContract({ allowedInputs: ["runner"] }), + }, + }, + }); + await assert.rejects(() => audit(root), /allowedInputs must include role when role is declared/); +}); + +test("a caller must pass the contract's role as a literal", async () => { + const root = await repository({ + policyOverrides: { + approvedReusableWorkflowContracts: { [FLEET_CLAUDE_REVIEW_REFERENCE]: roleContract() }, + }, + workflows: { "intake.yml": roleCaller("reviews") }, + }); + assert.ok( + (await audit(root)).some( + ({ rule, message }) => + rule === "runner-target-contract" && message.includes("must receive role: automations"), + ), + ); +}); + test("every runner-input contract accepts only whole named-secret mapping values", async () => { for (const expression of [ `\${{ toJSON(secrets) }}`, @@ -9379,6 +9462,28 @@ test("intake-triage at the v0.49.0 tag copies its v0.45.0 entry and adds only th ); }); +// v0.51.0 runs intake on run-claude-cli, funded by a named role; intake-triage +// is its v0.49.0 entry plus `role` and the automations role's two secrets. +test("intake-triage at the v0.51.0 tag copies its v0.49.0 entry and adds only the role input and secrets", () => { + const contracts = BASE_POLICY.approvedReusableWorkflowContracts; + const workflowPath = "melodic-software/ci-workflows/.github/workflows/intake-triage.yml"; + const previous = contracts[`${workflowPath}@${REPINE_LANE_SHA_V0_49_0}`]; + assert.ok(previous, "expected a v0.49.0 contract for intake-triage"); + assert.deepEqual(contracts[`${workflowPath}@${REPINE_LANE_SHA_V0_51_0}`], { + ...previous, + role: "automations", + allowedInputs: [...previous.allowedInputs, "role"], + allowedSecrets: { + CLAUDE_API_KEY: `\${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }}`, + CLAUDE_CODE_OAUTH_TOKEN: `\${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }}`, + }, + }); + assert.equal( + Object.keys(contracts).filter((key) => key.endsWith(`@${REPINE_LANE_SHA_V0_51_0}`)).length, + 1, + ); +}); + // Convergence as a property, never as a hardcoded SHA. The daily // claude-lanes-repin lane rewrites these components on every ci-workflows // release, and a test naming one revision would go red by construction on