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/ci-pr-verify-handoff-5e18f3d6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Changed

- pr-verify hands the judgment to the subscription job on run-claude-cli's `handoff` output instead of the `credit-stop` class, so a key the API refuses with status 401 or 402 (class `auth`) also falls back; a 403 still fails the key job. The key-leg warning says whether to replace the key or rotate one in. The subscription job's lane record is plain `pr-verify`, with the reason in `fallback-reason` (`credit-stop`, `auth` or `no-key`). Every ci-workflows action pr-verify uses is pinned to v0.58.0, whose run-claude-cli installs the latest Claude Code release by default.
2 changes: 2 additions & 0 deletions .github/actions/report-check-run/lane-record.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const CLASSES = new Set([
"overloaded",
"max-turns",
"timeout",
"install-failed",
"no-execution",
"other",
"skipped-validation",
"incomplete",
Expand Down
11 changes: 11 additions & 0 deletions .github/actions/report-check-run/lane-record.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ test("the output record drops what fails its shape: a reset off rate-limit, a fo
assert.equal(laneRecordOutput({ ...forgedRun, runId: "7", runAttempt: "0" }).externalId, null);
});

// The classes report-lane-outcome's failure-class output names at the pinned
// ci-workflows v0.56.0 beyond the original six.
test("an install-failed or no-execution run keeps its output record", () => {
for (const failureClass of ["install-failed", "no-execution"]) {
assert.equal(
laneRecordOutput({ lane: "pr-unblock", activity: "x", failureClass }).line,
`lane=pr-unblock activity=x class=${failureClass}`,
);
}
});

test("no record, an unknown class, or a malformed name add no output record", () => {
assert.equal(laneRecordOutput({ lane: "pr-unblock", activity: "x" }), null);
assert.equal(laneRecordOutput({ lane: "pr-unblock", activity: "x", failureClass: "forged class" }), null);
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-verify/probe/parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ test("cliArgs is lane.cjs's argv for this lane, plus --settings", () => {
});

test("the runner's CLI version, environment additions and managed settings", () => {
assert.equal(CLI_VERSION, "2.1.304");
assert.equal(CLI_VERSION, "latest");
assert.deepEqual(LEG_ENV, {
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
CLAUDE_CODE_RETRY_WATCHDOG: "1",
Expand Down
15 changes: 9 additions & 6 deletions .github/scripts/pr-verify/probe/replay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
// https://code.claude.com/docs/en/settings-reference (scope Managed), as of
// 2026-10-20; recheck when that row's scope changes.
// - CLI version: CLI_VERSION, the cli-version default of run-claude-cli at
// RUN_CLAUDE_CLI_PIN; a live run refuses any other.
// RUN_CLAUDE_CLI_PIN. `latest` resolves, as lane.cjs does, with
// `npm view @anthropic-ai/claude-code version`; a live run refuses any
// other version.
// - Environment: built from an allowlist, then lane.cjs legEnv's additions
// and the judge step's env; nothing else of the caller's shell passes.
// - MCP: --strict-mcp-config with no --mcp-config, as on the runner.
Expand Down Expand Up @@ -58,8 +60,8 @@ const REPOSITORY = "melodic-software/claude-code-plugins";
// apply-claude-managed-settings to. cliArgs, LEG_ENV, CLI_VERSION and
// MANAGED_SETTINGS were read from that SHA; the parity test fails when the
// pin moves, so they are read again.
export const RUN_CLAUDE_CLI_PIN = "7b362428c76085a160e28656cd2c75c3147a69ff";
export const CLI_VERSION = "2.1.304";
export const RUN_CLAUDE_CLI_PIN = "5e18f3d6cebd6fa5e66b0941ee0f7ed6ba288d6c";
export const CLI_VERSION = "latest";
export const MANAGED_SETTINGS = Object.freeze({
apiKeyHelper: "",
allowManagedHooksOnly: true,
Expand Down Expand Up @@ -673,16 +675,17 @@ export async function main(argv = process.argv.slice(2)) {
const models = resolveModels();
// A dry run never starts the CLI, so it needs none installed.
const version = values.live ? /^(\d+\.\d+\.\d+)/.exec(sh(values.claude, ["--version"], { env: { PATH: process.env.PATH } }))?.[1] : "not checked";
const runnerVersion = values.live && CLI_VERSION === "latest" ? sh("npm", ["view", "@anthropic-ai/claude-code", "version"]) : CLI_VERSION;
const commit = sh("git", ["-C", REPO_ROOT, "rev-parse", "HEAD"]);
console.log(
`pr-verify replay: commit ${commit}; run-claude-cli ${RUN_CLAUDE_CLI_PIN}; claude ${version} (runner ${CLI_VERSION}); ` +
`pr-verify replay: commit ${commit}; run-claude-cli ${RUN_CLAUDE_CLI_PIN}; claude ${version} (runner ${runnerVersion}); ` +
`route ${JUDGE.modelRoute} -> ${models.profile}: model ${models.model}, effort ${models.effort || "default"}, max-turns ${models.maxTurns}; ` +
`${cases.length} case(s) x ${runs} run(s); ${values.live ? "live" : "dry run"}`,
);
const opts = { live: values.live, claude: values.claude, maxAttempts, out: values.out };
if (values.live) {
if (version !== CLI_VERSION) {
throw new Error(`claude is ${version}; the runner runs ${CLI_VERSION}`);
if (version !== runnerVersion) {
throw new Error(`claude is ${version}; the runner runs ${runnerVersion}`);
}
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
throw new Error("set CLAUDE_CODE_OAUTH_TOKEN (claude setup-token) for a live run");
Expand Down
64 changes: 42 additions & 22 deletions .github/scripts/pr-verify/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ const list = (block, key) => {
return body.split("\n").map((line) => line.trim()).filter(Boolean);
};
const PIN = /^melodic-software\/ci-workflows\/\.github\/actions\/([a-z-]+)@[0-9a-f]{40} # v\d+\.\d+\.\d+$/;
// The subscription job's one lane record names why the key did not judge.
const SUB_LANE =
"${{ needs.check-intent.outputs.lane-class == 'credit-stop' && 'pr-verify (key AUTOMATIONS_CLAUDE_API_KEY hit a credit stop)' || 'pr-verify (key AUTOMATIONS_CLAUDE_API_KEY is empty)' }}";
// The subscription job's one lane record names why the key did not judge,
// in report-lane-outcome's fallback-reason (ci-workflows intake-triage.yml).
const FALLBACK_REASON =
"${{ needs.check-intent.outputs.lane-class == 'credit-stop' && 'credit-stop' || needs.check-intent.outputs.lane-class == 'auth' && 'auth' || needs.check-intent.outputs.credential-present == 'false' && 'no-key' || '' }}";
const KEY_CLASS = "${{ steps.judge.outputs.lane-class == 'credit-stop' && 'rate-limit' || steps.judge.outputs.lane-class }}";
const KEY_REPORT_IF =
"(steps.judge.outcome == 'success' || steps.judge.outcome == 'failure') && (steps.judge.outputs.lane-class != 'credit-stop' || steps.key-leg.outcome == 'failure')";
"(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"],
Expand Down Expand Up @@ -130,7 +131,7 @@ test("each secret appears only in its own job", () => {
assert.doesNotMatch(text, /secrets: inherit|toJSON\(secrets\)/);
});

test("the subscription job runs only on a clean credit stop or an empty key, on a fixed runner", () => {
test("the subscription job runs only on a clean handoff or an empty key, on a fixed runner", () => {
const job = jobText(SUB);
assert.match(job, /^ {4}needs: check-intent$/m);
const gate = /^ {4}if: >-\n((?: {6}.*\n)+)/m.exec(job)?.[1].replace(/\s+/g, " ").trim();
Expand All @@ -141,27 +142,28 @@ test("the subscription job runs only on a clean credit stop or an empty key, on
"&& github.event.workflow_run.event == 'pull_request'",
"&& github.event.workflow_run.conclusion == 'success'",
"&& github.event.workflow_run.head_repository.full_name == github.repository",
"&& ((needs.check-intent.outputs.lane-class == 'credit-stop'",
"&& ((needs.check-intent.outputs.handoff == 'true'",
"&& needs.check-intent.outputs.key-leg-clean == 'true')",
"|| needs.check-intent.outputs.credential-present == 'false')",
].join(" "),
);
// No status function, so GitHub adds success(): a failed key job skips it.
assert.doesNotMatch(gate, /always\(\)|failure\(\)|cancelled\(\)|success\(\)/);
assert.match(job, /^ {4}runs-on: ubuntu-24\.04$/m);
// The key job's outputs reach only that if and the record's lane name,
// which they pick between two literals; never a run: script.
// The key job's outputs reach only that if and the record's fallback
// reason, which they pick between three literals; never a run: script.
const steps = job.slice(job.indexOf("\n steps:\n"));
assert.deepEqual(
steps.split("\n").filter((line) => line.includes("needs.")),
[` lane: ${SUB_LANE}`],
[` fallback-reason: ${FALLBACK_REASON}`],
);
const key = jobText(KEY);
const outputs = /^ {4}outputs:\n((?: {6}.*\n)+)/m.exec(key)?.[1];
assert.equal(
outputs,
[
" lane-class: ${{ steps.judge.outputs.lane-class }}",
" handoff: ${{ steps.judge.outputs.handoff }}",
" credential-present: ${{ steps.credential.outputs.present }}",
" key-leg-clean: ${{ steps.key-leg.outputs.clean }}",
"",
Expand All @@ -173,20 +175,28 @@ test("the subscription job runs only on a clean credit stop or an empty key, on
}
});

test("the key job hands the judgment on only after a clean credit stop or with no key", () => {
test("the key job hands the judgment on only after a clean handoff or with no key", () => {
const leg = step("Check the key leg");
assert.match(leg, /^ {8}if: steps\.judge\.outputs\.lane-class == 'credit-stop'$/m);
// run-claude-cli's handoff: a credit stop, or auth with API status 401 or
// 402; a 403 is auth too but hands nothing off, so it fails the key job.
assert.match(leg, /^ {8}if: steps\.judge\.outputs\.handoff == 'true'$/m);
assert.match(leg, /^ {10}LOG_FILE: \$\{\{ steps\.judge\.outputs\.log-file \}\}$/m);
assert.match(leg, /^ {10}LANE_CLASS: \$\{\{ steps\.judge\.outputs\.lane-class \}\}$/m);
assert.match(leg, /write-verdict\.mjs check-key-leg\n/);
// It runs before the report, which reads its outcome, and its failure
// leaves the red to "Fail without a verdict" after the report.
assert.match(leg, /^ {8}continue-on-error: true$/m);
assert.ok(index("Check the key leg") === index("Judge the pull request") + 1);
assert.ok(index("Report the judgment's outcome") === index("Check the key leg") + 1);
// The owner's signal to rotate a key, printed before the denial check can stop the step.
// The owner's signal to replace the key (auth) or rotate one in (a credit
// stop), printed before the denial check can stop the step. The class
// reaches the script through env, never interpolated.
const run = /^ {8}run: \|\n((?: {10}.*\n?)+)/m.exec(leg)[1];
assert.match(run.split("\n")[0], /^ {10}echo "::warning::the automations role's API key \(AUTOMATIONS_CLAUDE_API_KEY\) hit a credit stop/);
assert.ok(run.indexOf("::warning::") < run.indexOf("check-key-leg"));
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.doesNotMatch(run, /\$\{\{/);
assert.ok(run.lastIndexOf("::warning::") < run.indexOf("check-key-leg"));
assert.equal(index("Check the key leg", SUB), -1);
for (const job of JOBS) {
const credential = step("Check the credential", job);
Expand All @@ -211,7 +221,7 @@ test("the subscription job repeats the key job's steps but for the credential",
.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 }}")
.replace(`lane: ${SUB_LANE}`, "lane: pr-verify")
.replace(`\n fallback-reason: ${FALLBACK_REASON}`, "")
.replace(/ && steps\.credential\.outputs\.present != 'false' && steps\.key-leg\.outcome != 'success'$/m, ""),
);
assert.deepEqual(normalize(STEPS[SUB]), normalize(STEPS[KEY]));
Expand Down Expand Up @@ -272,7 +282,8 @@ test("the outcome is reported whenever a judge ran to a final class, and a missi
}
assert.match(step("Report the judgment's outcome", SUB), /^ {8}if: steps\.judge\.outcome == 'success' \|\| steps\.judge\.outcome == 'failure'$/m);
assert.equal(input(step("Report the judgment's outcome", SUB), "known-failure-class"), "${{ steps.judge.outputs.lane-class }}");
assert.equal(input(step("Report the judgment's outcome", SUB), "lane"), SUB_LANE);
assert.equal(input(step("Report the judgment's outcome", SUB), "lane"), "pr-verify");
assert.equal(input(step("Report the judgment's outcome", SUB), "fallback-reason"), FALLBACK_REASON);
// Only a hand-off spares the key job its red; an empty token fails the subscription job.
assert.match(
STEPS[KEY].at(-1).block,
Expand All @@ -281,18 +292,27 @@ test("the outcome is reported whenever a judge ran to a final class, and a missi
assert.match(STEPS[SUB].at(-1).block, /^ {8}if: always\(\) && steps\.agree\.outcome == 'success' && steps\.verdict\.outcome != 'success'$/m);
});

test("a credit stop leaves exactly one lane record, from the job that saw its end", () => {
test("a handoff leaves exactly one lane record, from the job that saw its end", () => {
const report = step("Report the judgment's outcome", KEY);
assert.equal(/^ {8}if: (.*)$/m.exec(report)[1], KEY_REPORT_IF);
assert.equal(input(report, "lane"), "pr-verify");
assert.equal(input(report, "fallback-reason"), undefined);
// A key leg that is not clean hands nothing on, so the key job records the
// stop itself, as the class report-lane-outcome knows for a credit stop.
// stop itself: a credit stop as rate-limit, the class report-lane-outcome
// knows for it, and auth as auth.
assert.equal(input(report, "known-failure-class"), KEY_CLASS);
// A clean stop skips this report; the subscription job, which runs exactly
// when key-leg-clean is true, records the run and names the stop in its lane.
// A clean handoff skips this report; the subscription job, which runs
// exactly when key-leg-clean is true, records the run with a plain lane
// name and says why in fallback-reason, one of report-lane-outcome's
// credit-stop, auth or no-key.
assert.match(jobText(SUB), /needs\.check-intent\.outputs\.key-leg-clean == 'true'/);
assert.match(SUB_LANE, /lane-class == 'credit-stop' && '[^']*credit stop\)'/);
assert.doesNotMatch(SUB_LANE, /=[a-z]/, "no key=value token a lane reader could parse");
assert.deepEqual([...FALLBACK_REASON.matchAll(/&& '([a-z-]+)'/g)].map((m) => m[1]), ["credit-stop", "auth", "no-key"]);
assert.match(FALLBACK_REASON, /lane-class == 'auth' && 'auth'/);
// No gate keys the subscription leg on the credit-stop class any more:
// run-claude-cli's handoff output is the one signal.
for (const line of text.split("\n").filter((l) => /^\s+(- )?if: /.test(l) || /^\s+&& /.test(l))) {
assert.doesNotMatch(line, /lane-class [!=]= 'credit-stop'/, line);
}
});

test("every ci-workflows action is pinned to one release", () => {
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-verify/write-verdict.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function earlierDenials(logText) {
}
}

// After the key leg's credit stop the subscription job judges on a new VM
// After the key leg hands off, the subscription job judges on a new VM
// that cannot read this leg's log, so the key job records here whether the
// leg was free of denials; the subscription job runs only on clean=true.
export function checkKeyLeg(env = process.env) {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/intake-label-needs-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
if: ${{ !cancelled() && needs.born-triaged.outputs.skip != 'true' }}
permissions:
issues: write # passed to the reusable, which reads and adds issue labels
uses: melodic-software/ci-workflows/.github/workflows/intake-label-needs-triage.yml@182aca3ad32b79fd002def94e58eb498ac49e7af # v0.42.0
uses: melodic-software/ci-workflows/.github/workflows/intake-label-needs-triage.yml@1bb8953644d16f21d10718962c6702f01ea3308a # v0.56.0
with:
runner: ubuntu-24.04
label: needs-triage
15 changes: 10 additions & 5 deletions .github/workflows/intake-reenter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ name: intake-reenter
# default branch only.
#
# Accepted exposure: only a trusted comment decides whether a re-entry
# happens, but the triage run it starts reads every comment on the issue, so
# any commenter's text reaches the triage model on each re-entry. Triage can
# only apply allowlisted labels and post with mentions broken, holds no write
# tools, and never adds `agent-ready`. Passing only trusted comments on a
# dispatch belongs in ci-workflows' intake-triage workflow.
# happens, and the triage run it starts passes the model only comments whose
# author id is in intake-triage.yml's allowed-author-ids (a person) or
# allowed-bot-sender-ids (a bot), never the lane's own triage comment. That
# bot list holds github-actions[bot], the dispatcher, so any other comment a
# workflow posts with a GITHUB_TOKEN counts as trusted too. The
# issue's title and body still reach the model on each re-entry, and that
# text, like the trusted comments, stays untrusted data. The model holds no
# GitHub token and only Read, Grep and Glob; a job with no model applies only
# allowlisted labels, posts with mentions broken, and never adds
# `agent-ready`.
on:
issue_comment:
types: [created]
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/intake-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ jobs:
# 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.
# 41898282 starts runs only for its dispatches. The same list picks the
# comments the model reads, so any comment a GITHUB_TOKEN posts (other
# than the lane's own marker comment) reaches the model as trusted text.
- name: Compose the bot id list
id: bots
env:
Expand Down Expand Up @@ -176,7 +178,7 @@ jobs:
concurrency:
group: intake-triage-${{ github.event.issue.number || inputs.issue-number || github.run_id }}
cancel-in-progress: false
uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@7b362428c76085a160e28656cd2c75c3147a69ff # v0.54.0
uses: melodic-software/ci-workflows/.github/workflows/intake-triage.yml@1bb8953644d16f21d10718962c6702f01ea3308a # v0.56.0
with:
runner: ubuntu-24.04
role: automations
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/maintenance-audit-test-selection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
.github/requirements-ci.txt
plugins/animation/requirements.txt
- name: Install and verify ShellCheck toolchain
uses: melodic-software/ci-workflows/.github/actions/shellcheck@ab83b01273026ab5c23c6f3b40e946d97a863fa2 # v0.39.3
uses: melodic-software/ci-workflows/.github/actions/shellcheck@5e18f3d6cebd6fa5e66b0941ee0f7ed6ba288d6c # v0.58.0
with:
paths: plugins/bash-format
rcfile: .shellcheckrc
Expand Down
Loading
Loading