Compare commits

...

28 Commits

Author SHA1 Message Date
GitHub Actions
be7b93b190 chore: bump Claude Code to 2.1.220 and Agent SDK to 0.3.220 2026-07-25 01:36:28 +00:00
GitHub Actions
e0cf66d1d2 chore: bump Claude Code to 2.1.219 and Agent SDK to 0.3.219 2026-07-24 17:14:37 +00:00
GitHub Actions
44423bdec7 chore: bump Claude Code to 2.1.218 and Agent SDK to 0.3.218 2026-07-22 21:27:29 +00:00
KeisukeYamashita
b00a3414fd
fix: share one exchanged WIF credential across spawned Claude processes (#1407)
* fix: share one exchanged WIF credential across spawned Claude processes

GitHub OIDC tokens are single-use at the Anthropic token-exchange
endpoint (the same jti cannot be exchanged twice). With plugins
configured, the action spawns several short-lived claude processes
(plugin marketplace add, one plugin install per plugin, then the main
query). Each resolved federation from bare env vars and exchanged the
same identity-token file independently: the first exchange succeeded
and every later process got 401 (jti_reused), which the main query
retried for ~3 minutes before failing the job.

The SDK only enables its on-disk credentials cache when federation is
loaded from a profile config file, not from bare env vars. Write a
profile pointing at the identity-token file and select it via
ANTHROPIC_CONFIG_DIR / ANTHROPIC_PROFILE so the first process exchanges
once and the rest reuse the cached access token. The env vars are kept
as a fallback for CLIs that predate profile support.

* fix: scope the WIF credential cache per federation config

Address review feedback on the shared-credentials-cache fix:

- Embed a fingerprint of the federation inputs (rule, org, service
  account, workspace, base URL, scope) in the config dir name. The SDK
  cache reuses a token on expires_at alone and RUNNER_TEMP is per-job,
  so a later step with different federation inputs would silently reuse
  the first step's token. service_account_id and scope are included
  beyond the reviewed list because both are sent in the exchange
  request body and change which credential is minted.
- Skip the action-managed profile with a warning when the operator has
  already set ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE.
- Shrink the profile to the minimal file-backed form; the CLI's bundled
  SDK gap-fills the federation fields from the env vars the action
  already exports (verified against the pinned 2.1.173 binary).
- Remove the token dir in stop() so the identity token and the cached
  exchanged credential don't outlive the step.
- Document that cache sharing relies on the plugin subprocesses
  spawning sequentially.
2026-07-22 07:02:41 -07:00
GitHub Actions
fa7e2f0a29 chore: bump Claude Code to 2.1.217 and Agent SDK to 0.3.217 2026-07-21 21:35:48 +00:00
GitHub Actions
b76a0776ae chore: bump Claude Code to 2.1.216 and Agent SDK to 0.3.216 2026-07-20 22:14:27 +00:00
GitHub Actions
af0559ee4f chore: bump Claude Code to 2.1.215 and Agent SDK to 0.3.215 2026-07-19 02:56:28 +00:00
GitHub Actions
3553f84341 chore: bump Claude Code to 2.1.214 and Agent SDK to 0.3.214 2026-07-18 01:20:51 +00:00
GitHub Actions
700e7f8316 chore: bump Claude Code to 2.1.212 and Agent SDK to 0.3.212 2026-07-17 00:27:04 +00:00
Paarth
3e807ec379
fix: handle null comment/review author from deleted accounts (#1490)
GitHub's GraphQL author field is null when the account behind a
comment, review, PR, or issue has been deleted (the ghost user). The
action typed author as non-null and read author.login directly, so a
single comment from a deleted account threw and was swallowed into a
generic 'Failed to fetch PR/issue data', failing the entire run.

Make author nullable on the four affected types and fall back to
'ghost' at each login read. With the type nullable, tsc flags every
dereference, so all sites are covered.
2026-07-15 21:00:22 -07:00
farmer
2988cbe14a
docs: map custom_instructions to --append-system-prompt (#1480) (#1484)
* docs: map custom_instructions to --append-system-prompt (#1480)

The v1 migration guide mapped the v0 `custom_instructions` input to
`claude_args: --system-prompt`, but these have different semantics:
`custom_instructions` *appended* to Claude Code's default system prompt,
while `--system-prompt` *replaces* it entirely. Users who followed the
guide silently lost the whole built-in system prompt (tool-usage guidance,
sub-agent conventions, etc.), keeping only their few custom lines.

Fixes #1480:
- Map `custom_instructions` -> `--append-system-prompt` (matches v0 append
  semantics) in the deprecated-inputs table, the migration example, and the
  checklist.
- Correct the claude_args options table: `--system-prompt` replaces the
  entire prompt; add an `--append-system-prompt` row for append behavior.

Docs-only; no code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: fix remaining custom_instructions migration references (#1480)

Update usage.md, faq.md, and configuration.md to map custom_instructions
to --append-system-prompt, matching the migration-guide fix. The
override_prompt row is left unchanged since replacement semantics may be
intended there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:35:32 -07:00
anish
a1c0599a9c
fix(format): filter out thinking_tokens system messages from step summary (#1479)
## Summary

Signed-off-by: anish <anishesg@users.noreply.github.com>
Co-authored-by: anish <anishesg@users.noreply.github.com>
2026-07-15 20:27:23 -07:00
Riley Mete
5bfa96a5b0
fix: allow leading underscore in branch names (valid per git-check-ref-format) (#1486)
Branch names starting with an underscore (e.g. _release/v1.2.3) are valid
per git check-ref-format but were rejected by validateBranchName's
first-character whitelist. Since setupBranch validates a PR's baseRefName
after checkout, the action failed on every open PR targeting such a
branch. A leading underscore carries no option-injection risk (only a
leading dash does, which is still rejected separately).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 20:27:12 -07:00
Jianke LIN
214a70611b
fix: map claude_args model to SDK options (#1474) 2026-07-15 20:22:15 -07:00
Humphrey
5f509a1c1f
fix(sanitizer): strip alt text from reference-style markdown images (#1488)
stripMarkdownImageAltText removed alt text from inline images
(![alt](url)) but not reference-style images (![alt][ref]), because the
regex requires the "](" of the inline form. Alt text is a
hidden-instruction channel that reaches the prompt via sanitizeContent,
so the reference-style form let it survive.

Add a matching replace for the reference-style form (![alt][ref] ->
![][ref]), preserving the [ref] label so the image definition still
resolves. Adds regression tests.

Co-authored-by: Contributor <you@example.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:21:12 -07:00
Paarth
e64308ff97
fix: sanitize {{label}} in branch name templates (#1492)
A scoped label like area:permissions was substituted into the branch
name verbatim, producing a ":" that validateBranchName rejects. Because
the branch setup block catches that error and calls process.exit(1), the
whole run died. {{description}} was already sanitized via extractDescription;
{{label}} was the only free-text variable that skipped it.

Add a sanitizeLabel helper (replaces invalid-char runs with a hyphen so
scoped labels stay readable) and apply it before substitution, falling back
to entityType when a label sanitizes to empty. Adds regression tests that
also assert the result passes validateBranchName.
2026-07-15 20:21:02 -07:00
farmer
58dc33d9ad
test: cover prepareContext validation error branches (#1460)
* test: cover prepareContext validation error branches

create-prompt.test.ts exercised only happy paths; the ~20 validation
guards in prepareContext (missing PR_NUMBER, unsupported event type,
unsupported issue action, missing claude branch, etc.) had no coverage.

Adds a "prepareContext validation errors" block asserting the thrown
messages for the reachable guards, using the existing createMockContext
helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: cover comments/common link and body builders

`src/github/operations/comments/common.ts` had no direct test coverage,
though its exports are live code used by create-initial.ts and
update-with-branch.ts. This adds unit tests for all four exports:
SPINNER_HTML, createJobRunLink, createBranchLink, and createCommentBody.

Assertions are built from the imported GITHUB_SERVER_URL so they hold on
GHES as well as github.com. Pure test additions — no production changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:20:53 -07:00
evol1228
ae650f8355
docs: fix dead example links in custom-automations.md (#1513) 2026-07-15 20:18:08 -07:00
GitHub Actions
1298632ce7 chore: bump Claude Code to 2.1.211 and Agent SDK to 0.3.211 2026-07-15 23:10:52 +00:00
GitHub Actions
1253134445 chore: bump Claude Code to 2.1.210 and Agent SDK to 0.3.210 2026-07-14 23:46:09 +00:00
NickNojiri
4f07c81564
fix(sanitizer): redact GitHub user-to-server (ghu_) tokens (#1502)
redactGitHubTokens covers ghp_, gho_, ghs_, ghr_, and github_pat_
tokens but misses ghu_ (GitHub App user-to-server tokens), one of the
documented GitHub token prefixes. A ghu_ token appearing in issue or
PR content passed through sanitization unredacted.

Add the ghu_ pattern, mirroring the existing 40-character token
patterns, with unit tests including the git-credential URL form.
2026-07-14 13:06:18 -07:00
GitHub Actions
f1bd27ca5b chore: bump Claude Code to 2.1.209 and Agent SDK to 0.3.209 2026-07-14 06:36:42 +00:00
GitHub Actions
a08f8913d5 chore: bump Claude Code to 2.1.208 and Agent SDK to 0.3.208 2026-07-14 01:11:18 +00:00
石岳峰
972a512078
fix(sdk): fail step when result has is_error:true despite success subtype (#1496)
Treat subtype success with is_error:true as a failed run so CI does not
show a misleading green check when the review never actually ran.

Fixes #1495

Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
2026-07-13 09:01:51 -07:00
GitHub Actions
e90deca476 chore: bump Claude Code to 2.1.207 and Agent SDK to 0.3.207 2026-07-11 00:52:42 +00:00
GitHub Actions
536f2c32a3 chore: bump Claude Code to 2.1.206 and Agent SDK to 0.3.206 2026-07-09 23:35:14 +00:00
GitHub Actions
37b464ce72 chore: bump Claude Code to 2.1.205 and Agent SDK to 0.3.205 2026-07-08 21:22:46 +00:00
GitHub Actions
ba0aafd430 chore: bump Claude Code to 2.1.204 and Agent SDK to 0.3.204 2026-07-08 00:28:40 +00:00
33 changed files with 818 additions and 101 deletions

View File

@ -145,7 +145,7 @@ runs:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: | run: |
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.1.203" CLAUDE_CODE_VERSION="2.1.220"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."

View File

@ -6,7 +6,7 @@
"name": "@anthropic-ai/claude-code-base-action", "name": "@anthropic-ai/claude-code-base-action",
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.203", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
}, },
"devDependencies": { "devDependencies": {
@ -27,23 +27,23 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],

View File

@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.203", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -75,7 +75,8 @@ async function run() {
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");
process.exit(1); process.exit(1);
} finally { } finally {
// Stop refreshing the workload identity token file so the process can exit // Stop refreshing the workload identity token file (so the process can
// exit) and delete the token material so it doesn't outlive this step
workloadIdentity?.stop(); workloadIdentity?.stop();
} }
} }

View File

@ -201,6 +201,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Detect if --json-schema is present (for hasJsonSchema flag) // Detect if --json-schema is present (for hasJsonSchema flag)
const hasJsonSchema = "json-schema" in extraArgs; const hasJsonSchema = "json-schema" in extraArgs;
const modelFromClaudeArgs = extraArgs["model"] || undefined;
delete extraArgs["model"];
const additionalDirectories = extraArgs["add-dir"] const additionalDirectories = extraArgs["add-dir"]
? extraArgs["add-dir"] ? extraArgs["add-dir"]
.split(ACCUMULATE_DELIMITER) .split(ACCUMULATE_DELIMITER)
@ -304,7 +307,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Build SDK options - use merged tools from both direct options and claudeArgs // Build SDK options - use merged tools from both direct options and claudeArgs
const sdkOptions: SdkOptions = { const sdkOptions: SdkOptions = {
// Direct options from ClaudeOptions inputs // Direct options from ClaudeOptions inputs
model: options.model, model: options.model || modelFromClaudeArgs,
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined, maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
allowedTools: allowedTools:
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined, mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,

View File

@ -208,7 +208,10 @@ export async function runClaudeWithSdk(
throw new Error("No result message received from Claude"); throw new Error("No result message received from Claude");
} }
const isSuccess = resultMessage.subtype === "success"; // subtype "success" with is_error:true means the run errored without producing
// a real result — treat it as failure so CI does not show a misleading green check.
const isSuccess =
resultMessage.subtype === "success" && !resultMessage.is_error;
result.conclusion = isSuccess ? "success" : "failure"; result.conclusion = isSuccess ? "success" : "failure";
// Handle structured output // Handle structured output
@ -234,14 +237,21 @@ export async function runClaudeWithSdk(
} }
if (!isSuccess) { if (!isSuccess) {
if (resultMessage.subtype === "success" && resultMessage.is_error) {
core.error(
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
);
}
if ("errors" in resultMessage && resultMessage.errors) { if ("errors" in resultMessage && resultMessage.errors) {
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`); core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
} }
throw new Error( throw new Error(
`Claude execution failed: ${ `Claude execution failed: ${
"errors" in resultMessage && resultMessage.errors resultMessage.subtype === "success" && resultMessage.is_error
? resultMessage.errors.join(", ") ? "result is_error:true"
: "unknown error" : "errors" in resultMessage && resultMessage.errors
? resultMessage.errors.join(", ")
: "unknown error"
}`, }`,
); );
} }

View File

@ -15,7 +15,8 @@
*/ */
import * as core from "@actions/core"; import * as core from "@actions/core";
import { mkdirSync, writeFileSync } from "fs"; import { createHash } from "crypto";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { retryWithBackoff } from "./retry"; import { retryWithBackoff } from "./retry";
@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) {
return retryWithBackoff(() => core.getIDToken(audience)); return retryWithBackoff(() => core.getIDToken(audience));
} }
/**
* Writes a profile config that switches federation resolution to the
* file-backed path. Resolving federation through a profile (rather than bare
* env vars) enables the SDK's on-disk credentials cache, so the several
* `claude` processes the action spawns (plugin installs, main query) share
* one exchanged access token instead of each re-exchanging the single-use
* GitHub OIDC token, which fails with 401 (`jti_reused`).
*
* The profile is intentionally minimal: the SDK gap-fills the federation
* fields (rule, organization, identity-token file, service account, base URL)
* from the ANTHROPIC_* env vars the action already exports, so the file only
* needs to exist to turn the cache on.
*
* The config dir name embeds a fingerprint of the federation inputs. The
* SDK's cache reuses a token on `expires_at` alone, with no record of the
* config that minted it, and the token's scope is bound at mint time so a
* later action step in the same job (RUNNER_TEMP is per-job) with different
* federation inputs must land in a different dir or it would silently reuse
* the first step's token.
*
* Sharing the cache is only safe while the action spawns its `claude`
* subprocesses sequentially: the SDK cache is not cross-process serialized,
* and concurrent cache misses would each re-exchange the same single-use
* identity token. Parallelizing the plugin installs would reintroduce the
* `jti_reused` failures.
*/
function writeFederationProfile(baseDir: string): string {
// Every input that changes which credential the exchange mints must be in
// here; service_account_id and scope are sent in the exchange request body.
const fingerprint = createHash("sha256")
.update(
JSON.stringify([
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "",
process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "",
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "",
process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "",
process.env.ANTHROPIC_BASE_URL?.trim() ?? "",
process.env.ANTHROPIC_SCOPE?.trim() ?? "",
]),
)
.digest("hex")
.slice(0, 16);
const configDir = join(baseDir, `config-${fingerprint}`);
mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 });
writeFileSync(
join(configDir, "configs", "default.json"),
JSON.stringify(
{ version: "1.0", authentication: { type: "oidc_federation" } },
null,
2,
),
{ mode: 0o600 },
);
return configDir;
}
/** /**
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP, * Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so * exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
@ -57,7 +115,8 @@ async function fetchIdentityToken(audience: string) {
* *
* Returns undefined when federation is not configured or is shadowed by a * Returns undefined when federation is not configured or is shadowed by a
* higher-precedence credential. Callers must invoke stop() when execution * higher-precedence credential. Callers must invoke stop() when execution
* finishes. * finishes; it also deletes the identity token and any cached exchanged
* credential.
*/ */
export async function setupWorkloadIdentity(): Promise< export async function setupWorkloadIdentity(): Promise<
WorkloadIdentityHandle | undefined WorkloadIdentityHandle | undefined
@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise<
} }
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile; process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
if (
process.env.ANTHROPIC_CONFIG_DIR?.trim() ||
process.env.ANTHROPIC_PROFILE?.trim()
) {
core.warning(
"ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.",
);
} else {
process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir);
process.env.ANTHROPIC_PROFILE = "default";
}
console.log( console.log(
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`, `Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
); );
@ -115,6 +185,12 @@ export async function setupWorkloadIdentity(): Promise<
return { return {
tokenFile, tokenFile,
stop: () => clearInterval(refreshInterval), stop: () => {
clearInterval(refreshInterval);
// RUNNER_TEMP is per-job, not per-step: remove the identity token, the
// profile, and the cached exchanged credential so they don't outlive
// this step.
rmSync(tokenDir, { recursive: true, force: true });
},
}; };
} }

View File

@ -106,7 +106,8 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
}); });
test("should handle hyphenated --allowed-tools flag", () => { test("should handle hyphenated --allowed-tools flag", () => {
@ -366,7 +367,8 @@ describe("parseSdkOptions", () => {
); );
expect(mcpConfig.mcpServers).toHaveProperty("server1"); expect(mcpConfig.mcpServers).toHaveProperty("server1");
expect(mcpConfig.mcpServers).toHaveProperty("server2"); expect(mcpConfig.mcpServers).toHaveProperty("server2");
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
}); });
test("should handle real-world scenario: action config + user config", () => { test("should handle real-world scenario: action config + user config", () => {
@ -436,7 +438,8 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]); expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
}); });
}); });
@ -464,7 +467,8 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-haiku");
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]); expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
}); });
@ -475,7 +479,8 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-haiku");
}); });
test("should not strip inline # that appears inside a quoted value", () => { test("should not strip inline # that appears inside a quoted value", () => {
@ -485,11 +490,37 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku"); expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-haiku");
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000"); expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
}); });
}); });
describe("model handling", () => {
test("should map --model from claudeArgs to sdkOptions.model", () => {
const options: ClaudeOptions = {
claudeArgs: "--model claude-haiku-4-5-20251001",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.model).toBe("claude-haiku-4-5-20251001");
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
});
test("should prefer direct model option over --model from claudeArgs", () => {
const options: ClaudeOptions = {
model: "claude-sonnet-4-6",
claudeArgs: "--model claude-haiku-4-5-20251001",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.model).toBe("claude-sonnet-4-6");
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
});
});
describe("environment variables passthrough", () => { describe("environment variables passthrough", () => {
test("should include OTEL environment variables in sdkOptions.env", () => { test("should include OTEL environment variables in sdkOptions.env", () => {
// Set up test environment variables // Set up test environment variables

View File

@ -63,4 +63,69 @@ describe("runClaudeWithSdk", () => {
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
} }
}); });
test("fails when result subtype is success but is_error is true", async () => {
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
() => {},
);
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
const coreErrorSpy = spyOn(
await import("@actions/core"),
"error",
).mockImplementation(() => {});
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
process.env.RUNNER_TEMP = tempDir;
const promptPath = join(tempDir, "prompt.txt");
await writeFile(promptPath, "test prompt");
const initMessage = {
type: "system",
subtype: "init",
session_id: "session-123",
model: "claude-sonnet-5",
};
const errorResultMessage = {
type: "result",
subtype: "success",
is_error: true,
duration_ms: 434,
num_turns: 1,
total_cost_usd: 0,
permission_denials: [],
};
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
query: async function* () {
yield initMessage;
yield errorResultMessage;
},
}));
try {
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
await expect(
runClaudeWithSdk(promptPath, {
sdkOptions: {},
showFullOutput: false,
hasJsonSchema: false,
}),
).rejects.toThrow("result is_error:true");
const executionFile = join(tempDir, "claude-execution-output.json");
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
JSON.stringify([initMessage, errorResultMessage], null, 2),
);
expect(coreErrorSpy).toHaveBeenCalledWith(
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
);
} finally {
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
coreErrorSpy.mockRestore();
}
});
}); });

View File

@ -2,7 +2,14 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs"; import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { import {
@ -27,6 +34,12 @@ describe("workload identity federation", () => {
delete process.env.ANTHROPIC_ORGANIZATION_ID; delete process.env.ANTHROPIC_ORGANIZATION_ID;
delete process.env.ANTHROPIC_OIDC_AUDIENCE; delete process.env.ANTHROPIC_OIDC_AUDIENCE;
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE; delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID;
delete process.env.ANTHROPIC_WORKSPACE_ID;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_SCOPE;
delete process.env.ANTHROPIC_CONFIG_DIR;
delete process.env.ANTHROPIC_PROFILE;
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue( getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
"test-identity-token", "test-identity-token",
@ -123,5 +136,123 @@ describe("workload identity federation", () => {
handle?.stop(); handle?.stop();
} }
}); });
test("writes a minimal federation profile and selects it", async () => {
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
process.env.ANTHROPIC_ORGANIZATION_ID =
"00000000-0000-0000-0000-000000000000";
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test";
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test";
const handle = await setupWorkloadIdentity();
try {
const configDir = process.env.ANTHROPIC_CONFIG_DIR;
expect(configDir).toBeDefined();
expect(
configDir!.startsWith(
join(tempDir, "claude-workload-identity", "config-"),
),
).toBe(true);
expect(process.env.ANTHROPIC_PROFILE).toBe("default");
const profilePath = join(configDir!, "configs", "default.json");
expect(statSync(profilePath).mode & 0o777).toBe(0o600);
// Minimal on purpose: the SDK gap-fills the federation fields from
// the ANTHROPIC_* env vars the action exports.
expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({
version: "1.0",
authentication: { type: "oidc_federation" },
});
} finally {
handle?.stop();
}
});
test("derives the config dir from the federation inputs", async () => {
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
process.env.ANTHROPIC_ORGANIZATION_ID =
"00000000-0000-0000-0000-000000000000";
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a";
(await setupWorkloadIdentity())?.stop();
const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
expect(firstConfigDir).toBeDefined();
// A later step in the same job with a different workspace must not
// share the first step's credentials cache.
delete process.env.ANTHROPIC_CONFIG_DIR;
delete process.env.ANTHROPIC_PROFILE;
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b";
(await setupWorkloadIdentity())?.stop();
const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
expect(secondConfigDir).toBeDefined();
expect(secondConfigDir).not.toBe(firstConfigDir);
// Same inputs land in the same dir, so an unchanged config can still
// reuse a cached token.
delete process.env.ANTHROPIC_CONFIG_DIR;
delete process.env.ANTHROPIC_PROFILE;
(await setupWorkloadIdentity())?.stop();
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!);
});
test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => {
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
process.env.ANTHROPIC_ORGANIZATION_ID =
"00000000-0000-0000-0000-000000000000";
process.env.ANTHROPIC_PROFILE = "operator";
const handle = await setupWorkloadIdentity();
try {
expect(process.env.ANTHROPIC_PROFILE).toBe("operator");
expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined();
expect(warningSpy).toHaveBeenCalled();
const entries = readdirSync(join(tempDir, "claude-workload-identity"));
expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]);
// The identity token file is still provisioned for the operator's
// profile (or the env-var fallback) to consume.
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
handle!.tokenFile,
);
} finally {
handle?.stop();
}
});
test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => {
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
process.env.ANTHROPIC_ORGANIZATION_ID =
"00000000-0000-0000-0000-000000000000";
const operatorConfigDir = join(tempDir, "operator-config");
process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir;
const handle = await setupWorkloadIdentity();
try {
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir);
expect(process.env.ANTHROPIC_PROFILE).toBeUndefined();
expect(warningSpy).toHaveBeenCalled();
} finally {
handle?.stop();
}
});
test("stop removes the identity token and credential cache", async () => {
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
process.env.ANTHROPIC_ORGANIZATION_ID =
"00000000-0000-0000-0000-000000000000";
const handle = await setupWorkloadIdentity();
const tokenDir = join(tempDir, "claude-workload-identity");
expect(existsSync(handle!.tokenFile)).toBe(true);
expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true);
handle!.stop();
expect(existsSync(tokenDir)).toBe(false);
});
}); });
}); });

View File

@ -7,7 +7,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@actions/github": "^6.0.1", "@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.203", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2", "@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
@ -37,23 +37,23 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.203", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.203", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.203", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.203" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-K/GMQlB3IpLtvqjI+/9Xcu//4wDRSxO2JimHMOM3zxHBKJA0EIb6U+4Ea0RSrxe+SHwFHT23XATL6U/9JggxXw=="], "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.203", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9uG6wp3reCtiWXA1H7NWZV5HFO6WgmedcpRCMpw0iyborLdw3MEZonJVlfRh93eQEVpflfnTOARTqtt7NzgHqg=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.203", "", { "os": "darwin", "cpu": "x64" }, "sha512-iP2cg+VovTYeU9f/l32Cq4cESNrgBvjZn/NipyhQ7RD468vBUjn22ii9cnGF7g9TepNrY3/IdkCPmwSea9besA=="], "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-p8wTbvWbQUscQBSefTdjwGbeVE6lYoEmMMdoNSOI8uR8jBr5YXoSwnSjBwmGDjz15WI4AIIdiWwyrf6sqrvqPA=="], "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.203", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQNRpgHKuavsEyvY3SnDRZuCxf1z2pjGI4Q73Q0GuIEapwXrY2UM0ucAj0b7KtS6hpu//CGhnBra2kKOXsDQAw=="], "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-YTW0+njIC61fZP3Qa9uzH9fOlEmApHG6SSxwyNxAQ8U3XX+yviL5/MyqLsauuUTVbyI9K7IqYOaE6xcDVDXIGg=="], "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.203", "", { "os": "linux", "cpu": "x64" }, "sha512-Hw2NNW8crM7ENR8peWZ+hG3ehUl9IYPzNxyqc1VM+odznB6squaki2xQeCcIatHbQImeW+DzrUFIDJLLz4pltg=="], "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.203", "", { "os": "win32", "cpu": "arm64" }, "sha512-EF2BPCSElFS9y76b/4TVU7RDw9xCr8DtxSUXAxYhcGsreo1gmJbMVdZ9tvLIvG6Ufquas4nrGmWh1ePLP2ol+g=="], "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.203", "", { "os": "win32", "cpu": "x64" }, "sha512-sYf4UokyYhYO6Nke99o+gtgCakoaohB+Q/71i+4hEq0FYqVf31WoJ6lUTTTUPeEGVp6Ju6BblzmAX2MpvlWwCw=="], "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],

View File

@ -337,17 +337,17 @@ For a complete list of available settings and their descriptions, see the [Claud
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate: Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
| Old Input | New Approach | | Old Input | New Approach |
| --------------------- | -------------------------------------------------------- | | --------------------- | --------------------------------------------------------------- |
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` | | `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` | | `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
| `max_turns` | Use `claude_args: "--max-turns 10"` | | `max_turns` | Use `claude_args: "--max-turns 10"` |
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` | | `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
| `claude_env` | Use `settings` with `"env"` object | | `claude_env` | Use `settings` with `"env"` object |
| `custom_instructions` | Use `claude_args: "--system-prompt 'Your instructions'"` | | `custom_instructions` | Use `claude_args: "--append-system-prompt 'Your instructions'"` |
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` | | `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
| `direct_prompt` | Use `prompt` input instead | | `direct_prompt` | Use `prompt` input instead |
| `override_prompt` | Use `prompt` with GitHub context variables | | `override_prompt` | Use `prompt` with GitHub context variables |
## Custom Executables for Specialized Environments ## Custom Executables for Specialized Environments

View File

@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
## Automated Documentation Updates ## Automated Documentation Updates
Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.yml)): Automatically update documentation when specific files change (see [`examples/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)):
```yaml ```yaml
on: on:
@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is
## Author-Specific Code Reviews ## Author-Specific Code Reviews
Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.yml)): Automatically review PRs from specific authors or external contributors (see [`examples/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)):
```yaml ```yaml
on: on:

View File

@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities"
**These inputs are deprecated in v1.0:** **These inputs are deprecated in v1.0:**
- **`direct_prompt`** → Use `prompt` instead - **`direct_prompt`** → Use `prompt` instead
- **`custom_instructions`** → Use `claude_args` with `--system-prompt` - **`custom_instructions`** → Use `claude_args` with `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely)
Migration examples: Migration examples:
@ -165,7 +165,7 @@ custom_instructions: "Focus on security"
# New (v1.0) # New (v1.0)
prompt: "Review this PR" prompt: "Review this PR"
claude_args: | claude_args: |
--system-prompt "Focus on security" --append-system-prompt "Focus on security"
``` ```
### Why doesn't Claude execute my bash commands? ### Why doesn't Claude execute my bash commands?

View File

@ -14,19 +14,19 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi
The following inputs have been deprecated and replaced: The following inputs have been deprecated and replaced:
| Deprecated Input | Replacement | Notes | | Deprecated Input | Replacement | Notes |
| --------------------- | ------------------------------------ | --------------------------------------------- | | --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
| `mode` | Auto-detected | Action automatically chooses based on context | | `mode` | Auto-detected | Action automatically chooses based on context |
| `direct_prompt` | `prompt` | Direct drop-in replacement | | `direct_prompt` | `prompt` | Direct drop-in replacement |
| `override_prompt` | `prompt` | Use GitHub context variables instead | | `override_prompt` | `prompt` | Use GitHub context variables instead |
| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments | | `custom_instructions` | `claude_args: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely |
| `max_turns` | `claude_args: --max-turns` | Use CLI format | | `max_turns` | `claude_args: --max-turns` | Use CLI format |
| `model` | `claude_args: --model` | Specify via CLI | | `model` | `claude_args: --model` | Specify via CLI |
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format | | `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format | | `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
| `claude_env` | `settings` with env object | Use settings JSON | | `claude_env` | `settings` with env object | Use settings JSON |
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments | | `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level | | `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
## Migration Examples ## Migration Examples
@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: | claude_args: |
--max-turns 10 --max-turns 10
--system-prompt "Follow our coding standards" --append-system-prompt "Follow our coding standards"
--allowedTools Edit,Read,Write --allowedTools Edit,Read,Write
``` ```
@ -255,14 +255,15 @@ claude_args: |
### Common claude_args Options ### Common claude_args Options
| Option | Description | Example | | Option | Description | Example |
| ------------------- | ------------------------ | -------------------------------------- | | ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ |
| `--max-turns` | Limit conversation turns | `--max-turns 10` | | `--max-turns` | Limit conversation turns | `--max-turns 10` |
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` | | `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` | | `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` | | `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
| `--system-prompt` | Add system instructions | `--system-prompt "Focus on security"` | | `--system-prompt` | Replace the entire default system prompt | `--system-prompt "Focus on security"` |
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` | | `--append-system-prompt` | Append to the default system prompt (keeps Claude Code's built-in prompt) | `--append-system-prompt "Follow our coding standards"` |
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
## Provider-Specific Updates ## Provider-Specific Updates
@ -330,7 +331,7 @@ You can also pass MCP configuration from a file:
- [ ] Remove `mode` input (auto-detected now) - [ ] Remove `mode` input (auto-detected now)
- [ ] Replace `direct_prompt` with `prompt` - [ ] Replace `direct_prompt` with `prompt`
- [ ] Replace `override_prompt` with `prompt` using GitHub context - [ ] Replace `override_prompt` with `prompt` using GitHub context
- [ ] Move `custom_instructions` to `claude_args` with `--system-prompt` - [ ] Move `custom_instructions` to `claude_args` with `--append-system-prompt`
- [ ] Convert `max_turns` to `claude_args` with `--max-turns` - [ ] Convert `max_turns` to `claude_args` with `--max-turns`
- [ ] Convert `model` to `claude_args` with `--model` - [ ] Convert `model` to `claude_args` with `--model`
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools` - [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`

View File

@ -99,7 +99,7 @@ These inputs are deprecated and will be removed in a future version:
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode | | `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` | | `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts | | `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` | | `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` | | `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` | | `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` | | `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
@ -139,7 +139,7 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: | claude_args: |
--max-turns 10 --max-turns 10
--system-prompt "Focus on security" --append-system-prompt "Focus on security"
``` ```
#### Automation Workflows #### Automation Workflows

View File

@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@actions/github": "^6.0.1", "@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.203", "@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2", "@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",

3
src/entrypoints/format-turns.ts Executable file → Normal file
View File

@ -268,7 +268,8 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
type: "system_init", type: "system_init",
tools_count: tools.length, tools_count: tools.length,
}); });
} else { } else if (subtype !== "thinking_tokens") {
// Skip thinking_tokens - internal progress events not meant for summary
groupedContent.push({ groupedContent.push({
type: "system_other", type: "system_other",
data: turn, data: turn,

View File

@ -75,7 +75,7 @@ async function installClaudeCode(): Promise<string> {
return customExecutable; return customExecutable;
} }
const claudeCodeVersion = "2.1.203"; const claudeCodeVersion = "2.1.220";
console.log(`Installing Claude Code v${claudeCodeVersion}...`); console.log(`Installing Claude Code v${claudeCodeVersion}...`);
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 3; attempt++) {
@ -318,7 +318,8 @@ async function run() {
} finally { } finally {
// Phase 4: Cleanup (always runs) // Phase 4: Cleanup (always runs)
// Stop refreshing the workload identity token file // Stop refreshing the workload identity token file and delete the token
// material so it doesn't outlive this step
workloadIdentity?.stop(); workloadIdentity?.stop();
// Update tracking comment // Update tracking comment

View File

@ -204,11 +204,9 @@ export function isBodySafeToUse(
* @param excludeActors - Comma-separated actors to exclude * @param excludeActors - Comma-separated actors to exclude
* @returns Filtered array of comments * @returns Filtered array of comments
*/ */
export function filterCommentsByActor<T extends { author: { login: string } }>( export function filterCommentsByActor<
comments: T[], T extends { author: { login: string } | null },
includeActors: string = "", >(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
excludeActors: string = "",
): T[] {
const includeParsed = parseActorFilter(includeActors); const includeParsed = parseActorFilter(includeActors);
const excludeParsed = parseActorFilter(excludeActors); const excludeParsed = parseActorFilter(excludeActors);
@ -219,7 +217,9 @@ export function filterCommentsByActor<T extends { author: { login: string } }>(
return comments.filter((comment) => return comments.filter((comment) =>
shouldIncludeCommentByActor( shouldIncludeCommentByActor(
comment.author.login, // author is null for comments from deleted ("ghost") accounts; treat them
// as the "ghost" login so filtering never dereferences null and crashes.
comment.author?.login ?? "ghost",
includeParsed, includeParsed,
excludeParsed, excludeParsed,
), ),

View File

@ -21,7 +21,7 @@ export function formatContext(
const prData = contextData as GitHubPullRequest; const prData = contextData as GitHubPullRequest;
const sanitizedTitle = sanitizeContent(prData.title); const sanitizedTitle = sanitizeContent(prData.title);
return `PR Title: ${sanitizedTitle} return `PR Title: ${sanitizedTitle}
PR Author: ${prData.author.login} PR Author: ${prData.author?.login ?? "ghost"}
PR Branch: ${prData.headRefName} -> ${prData.baseRefName} PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
PR State: ${prData.state} PR State: ${prData.state}
PR Labels: ${formatLabels(prData.labels.nodes)} PR Labels: ${formatLabels(prData.labels.nodes)}
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
const issueData = contextData as GitHubIssue; const issueData = contextData as GitHubIssue;
const sanitizedTitle = sanitizeContent(issueData.title); const sanitizedTitle = sanitizeContent(issueData.title);
return `Issue Title: ${sanitizedTitle} return `Issue Title: ${sanitizedTitle}
Issue Author: ${issueData.author.login} Issue Author: ${issueData.author?.login ?? "ghost"}
Issue State: ${issueData.state} Issue State: ${issueData.state}
Issue Labels: ${formatLabels(issueData.labels.nodes)}`; Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
} }
@ -71,7 +71,7 @@ export function formatComments(
body = sanitizeContent(body); body = sanitizeContent(body);
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`; return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
}) })
.join("\n\n"); .join("\n\n");
} }
@ -85,7 +85,7 @@ export function formatReviewComments(
} }
const formattedReviews = reviewData.nodes.map((review) => { const formattedReviews = reviewData.nodes.map((review) => {
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`; let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`;
if (review.body && review.body.trim()) { if (review.body && review.body.trim()) {
let body = review.body; let body = review.body;

View File

@ -27,7 +27,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
* This prevents command injection by ensuring only safe characters are used. * This prevents command injection by ensuring only safe characters are used.
* *
* Valid branch names: * Valid branch names:
* - Start with alphanumeric character or @ (not dash, to prevent option injection) * - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection)
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@) * - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@)
* - Do not start or end with a period * - Do not start or end with a period
* - Do not end with a slash * - Do not end with a slash
@ -68,12 +68,15 @@ export function validateBranchName(branchName: string): void {
// @ is valid per git-check-ref-format anywhere in a ref name, including the first character // @ is valid per git-check-ref-format anywhere in a ref name, including the first character
// (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/..."); // (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/...");
// the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below. // the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below.
// _ is valid per git-check-ref-format anywhere in a ref name, including the first character;
// leading underscores are a common convention for release/internal branches (e.g.
// "_release/v1.2.3"), which previously failed validation as a PR's base branch.
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk. // All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
const validPattern = /^[a-zA-Z0-9@][a-zA-Z0-9/_.#+,@-]*$/; const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/;
if (!validPattern.test(branchName)) { if (!validPattern.test(branchName)) {
throw new Error( throw new Error(
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`, `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
); );
} }

View File

@ -1,4 +1,8 @@
// Types for GitHub GraphQL query responses // Types for GitHub GraphQL query responses
// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying
// account has been deleted (the "ghost" user). Any field typed as
// `GitHubAuthor | null` can therefore be null at runtime and must be guarded.
export type GitHubAuthor = { export type GitHubAuthor = {
login: string; login: string;
name?: string; name?: string;
@ -8,7 +12,7 @@ export type GitHubComment = {
id: string; id: string;
databaseId: string; databaseId: string;
body: string; body: string;
author: GitHubAuthor; author: GitHubAuthor | null;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
lastEditedAt?: string; lastEditedAt?: string;
@ -39,7 +43,7 @@ export type GitHubFile = {
export type GitHubReview = { export type GitHubReview = {
id: string; id: string;
databaseId: string; databaseId: string;
author: GitHubAuthor; author: GitHubAuthor | null;
body: string; body: string;
state: string; state: string;
submittedAt: string; submittedAt: string;
@ -53,7 +57,7 @@ export type GitHubReview = {
export type GitHubPullRequest = { export type GitHubPullRequest = {
title: string; title: string;
body: string; body: string;
author: GitHubAuthor; author: GitHubAuthor | null;
baseRefName: string; baseRefName: string;
headRefName: string; headRefName: string;
headRefOid: string; headRefOid: string;
@ -95,7 +99,7 @@ export type GitHubPullRequest = {
export type GitHubIssue = { export type GitHubIssue = {
title: string; title: string;
body: string; body: string;
author: GitHubAuthor; author: GitHubAuthor | null;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
lastEditedAt?: string; lastEditedAt?: string;

View File

@ -10,7 +10,13 @@ export function stripInvisibleCharacters(content: string): string {
} }
export function stripMarkdownImageAltText(content: string): string { export function stripMarkdownImageAltText(content: string): string {
return content.replace(/!\[[^\]]*\]\(/g, "![]("); // Inline images: ![alt](url) -> ![](url)
content = content.replace(/!\[[^\]]*\]\(/g, "![](");
// Reference-style images: ![alt][ref] -> ![][ref] (keep the label, drop the
// alt text, which is otherwise a hidden-instruction channel just like the
// inline form above).
content = content.replace(/!\[[^\]]*\](\[[^\]]*\])/g, "![]$1");
return content;
} }
export function stripMarkdownLinkTitles(content: string): string { export function stripMarkdownLinkTitles(content: string): string {
@ -83,6 +89,12 @@ export function redactGitHubTokens(content: string): string {
"[REDACTED_GITHUB_TOKEN]", "[REDACTED_GITHUB_TOKEN]",
); );
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghu_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) // GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace( content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g, /\bghs_[A-Za-z0-9]{36}\b/g,

View File

@ -28,6 +28,20 @@ function extractDescription(
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
} }
/**
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
* dropped, keeping the label readable. Returns "" if nothing usable remains.
*/
function sanitizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
.replace(/-+/g, "-") // Collapse multiple hyphens
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}
export interface BranchTemplateVariables { export interface BranchTemplateVariables {
prefix: string; prefix: string;
entityType: string; entityType: string;
@ -78,7 +92,7 @@ export function generateBranchName(
entityNumber, entityNumber,
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`, timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
sha: sha?.substring(0, 8), // First 8 characters of SHA sha: sha?.substring(0, 8), // First 8 characters of SHA
label: label || entityType, // Fall back to entityType if no label label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label
description: title ? extractDescription(title) : undefined, description: title ? extractDescription(title) : undefined,
}; };

View File

@ -5,6 +5,7 @@ import {
applyBranchTemplate, applyBranchTemplate,
generateBranchName, generateBranchName,
} from "../src/utils/branch-template"; } from "../src/utils/branch-template";
import { validateBranchName } from "../src/github/operations/branch";
describe("branch template utilities", () => { describe("branch template utilities", () => {
describe("applyBranchTemplate", () => { describe("applyBranchTemplate", () => {
@ -144,6 +145,53 @@ describe("branch template utilities", () => {
expect(result).toBe("dev/enhancement-issue_789"); expect(result).toBe("dev/enhancement-issue_789");
}); });
it("should sanitize scoped labels that contain invalid git characters", () => {
const template = "{{prefix}}{{label}}/{{entityNumber}}";
const result = generateBranchName(
template,
"claude/",
"issue",
123,
undefined,
"area:permissions",
);
expect(result).toBe("claude/area-permissions/123");
// Regression: an unsanitized ":" here previously failed validateBranchName
// and crashed the run via process.exit(1).
expect(() => validateBranchName(result)).not.toThrow();
});
it("should replace spaces in labels with hyphens", () => {
const template = "{{prefix}}{{label}}-{{entityNumber}}";
const result = generateBranchName(
template,
"fix/",
"issue",
456,
undefined,
"needs review",
);
expect(result).toBe("fix/needs-review-456");
expect(() => validateBranchName(result)).not.toThrow();
});
it("should fall back to entityType when a label sanitizes to empty", () => {
const template = "{{prefix}}{{label}}-{{entityNumber}}";
const result = generateBranchName(
template,
"fix/",
"pr",
789,
undefined,
"🎉",
);
expect(result).toBe("fix/pr-789");
expect(() => validateBranchName(result)).not.toThrow();
});
it("should use description in template when provided", () => { it("should use description in template when provided", () => {
const template = "{{prefix}}{{description}}/{{entityNumber}}"; const template = "{{prefix}}{{description}}/{{entityNumber}}";
const result = generateBranchName( const result = generateBranchName(

View File

@ -0,0 +1,74 @@
import { describe, test, expect } from "bun:test";
import {
SPINNER_HTML,
createJobRunLink,
createBranchLink,
createCommentBody,
} from "../src/github/operations/comments/common";
import { GITHUB_SERVER_URL } from "../src/github/api/config";
describe("comments/common", () => {
describe("createJobRunLink", () => {
test("builds a markdown link to the workflow run", () => {
const result = createJobRunLink("anthropics", "claude-code-action", "42");
expect(result).toBe(
`[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`,
);
});
test("honors GITHUB_SERVER_URL (GHES) rather than hardcoding github.com", () => {
// The link is built from the configured server URL, so it must point at
// whatever GITHUB_SERVER_URL resolves to (github.com by default, a GHES
// host in enterprise setups).
expect(createJobRunLink("o", "r", "1")).toContain(GITHUB_SERVER_URL);
});
});
describe("createBranchLink", () => {
test("builds a leading-newline markdown link to the branch tree", () => {
const result = createBranchLink(
"anthropics",
"claude-code-action",
"feature/x",
);
expect(result).toBe(
`\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`,
);
});
test("prefixes the link with a newline so it renders on its own line", () => {
expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true);
});
});
describe("createCommentBody", () => {
test("includes the spinner, the working message, and the job run link", () => {
const jobRunLink = createJobRunLink("o", "r", "7");
const body = createCommentBody(jobRunLink);
expect(body).toContain(SPINNER_HTML);
expect(body).toContain("Claude Code is working…");
expect(body).toContain(jobRunLink);
});
test("omits the branch link when none is provided (defaults to empty)", () => {
const body = createCommentBody(createJobRunLink("o", "r", "7"));
expect(body).not.toContain("View branch");
// No trailing branch content: body ends with the job run link.
expect(body.endsWith(")")).toBe(true);
});
test("appends the branch link when provided", () => {
const jobRunLink = createJobRunLink("o", "r", "7");
const branchLink = createBranchLink("o", "r", "feature/x");
const body = createCommentBody(jobRunLink, branchLink);
expect(body).toContain(jobRunLink);
expect(body).toContain(branchLink);
// The branch link (with its leading newline) comes after the job run link.
expect(body.indexOf(branchLink)).toBeGreaterThan(
body.indexOf(jobRunLink),
);
});
});
});

View File

@ -6,8 +6,10 @@ import {
getEventTypeAndContext, getEventTypeAndContext,
buildAllowedToolsString, buildAllowedToolsString,
buildDisallowedToolsString, buildDisallowedToolsString,
prepareContext,
} from "../src/create-prompt"; } from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt";
import { createMockContext } from "./mockContext";
beforeAll(() => { beforeAll(() => {
process.env.GITHUB_ACTION_PATH = "/test/action/path"; process.env.GITHUB_ACTION_PATH = "/test/action/path";
@ -1270,3 +1272,83 @@ describe("buildDisallowedToolsString", () => {
expect(result).toBe("BadTool1,BadTool2"); expect(result).toBe("BadTool1,BadTool2");
}); });
}); });
describe("prepareContext validation errors", () => {
const commentId = "12345";
test("throws on an unsupported event type", () => {
const context = createMockContext({
eventName: "deployment_status" as any,
});
expect(() => prepareContext(context, commentId)).toThrow(
"Unsupported event type: deployment_status",
);
});
test("pull_request event requires a PR number (isPR must be true)", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: false,
});
expect(() => prepareContext(context, commentId)).toThrow(
"PR_NUMBER is required for pull_request event",
);
});
test("pull_request_review event requires a PR number", () => {
const context = createMockContext({
eventName: "pull_request_review",
isPR: false,
payload: {
review: { body: "please fix", user: { login: "user1" } },
} as any,
});
expect(() => prepareContext(context, commentId)).toThrow(
"PR_NUMBER is required for pull_request_review event",
);
});
test("issues event requires an event action", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "",
isPR: false,
payload: { issue: { user: { login: "user1" } } } as any,
});
expect(() => prepareContext(context, commentId)).toThrow(
"GITHUB_EVENT_ACTION is required for issues event",
);
});
test("issues event rejects an unsupported action", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "deleted",
isPR: false,
payload: { issue: { user: { login: "user1" } } } as any,
});
expect(() =>
prepareContext(context, commentId, "main", "claude/issue-1"),
).toThrow("Unsupported issue action: deleted");
});
test("issue_comment on an issue requires a claude branch", () => {
const context = createMockContext({
eventName: "issue_comment",
isPR: false,
payload: {
comment: { id: 999, body: "@claude help", user: { login: "user1" } },
} as any,
});
expect(() => prepareContext(context, commentId)).toThrow(
"CLAUDE_BRANCH is required for issue_comment event",
);
});
});

View File

@ -1499,4 +1499,42 @@ describe("filterCommentsByActor", () => {
const filtered = filterCommentsByActor(comments, "user1", ""); const filtered = filterCommentsByActor(comments, "user1", "");
expect(filtered).toHaveLength(0); expect(filtered).toHaveLength(0);
}); });
test("does not crash on comments from deleted (null-author) accounts", () => {
// GitHub's GraphQL returns author: null for comments whose account was
// deleted. With an exclude filter set (the exact `*[bot]` config we
// recommend), the null author must not throw when dereferenced.
const comments = [
{ author: { login: "user1" }, body: "comment1" },
{ author: null, body: "from a deleted account" },
{ author: { login: "bot[bot]" }, body: "comment3" },
];
const { filterCommentsByActor } = require("../src/github/data/fetcher");
const filtered = filterCommentsByActor(comments, "", "*[bot]");
// ghost comment is retained (it matches no exclude pattern); the bot is dropped.
expect(filtered).toHaveLength(2);
expect(filtered.map((c: any) => c.body)).toEqual([
"comment1",
"from a deleted account",
]);
});
test("treats null author as the 'ghost' login for include/exclude", () => {
const comments = [
{ author: null, body: "from a deleted account" },
{ author: { login: "user1" }, body: "comment2" },
];
const { filterCommentsByActor } = require("../src/github/data/fetcher");
// Excluding "ghost" removes the deleted-account comment.
expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1);
expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe(
"comment2",
);
// Including only "ghost" keeps just the deleted-account comment.
const onlyGhost = filterCommentsByActor(comments, "ghost", "");
expect(onlyGhost).toHaveLength(1);
expect(onlyGhost[0].body).toBe("from a deleted account");
});
}); });

View File

@ -159,6 +159,21 @@ Issue State: OPEN
Issue Labels: architecture, agent-sdk, drift:functional`, Issue Labels: architecture, agent-sdk, drift:functional`,
); );
}); });
test("renders a deleted (null-author) issue author as 'ghost'", () => {
const issueData: GitHubIssue = {
title: "Test Issue",
body: "Issue body",
author: null,
createdAt: "2023-01-01T00:00:00Z",
state: "OPEN",
labels: { nodes: [] },
comments: { nodes: [] },
};
const result = formatContext(issueData, false);
expect(result).toContain("Issue Author: ghost");
});
}); });
describe("formatBody", () => { describe("formatBody", () => {
@ -252,6 +267,24 @@ describe("formatComments", () => {
); );
}); });
test("renders deleted (null-author) comments as 'ghost'", () => {
// GitHub returns author: null for comments from deleted accounts.
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "From a deleted account",
author: null,
createdAt: "2023-01-01T00:00:00Z",
},
];
const result = formatComments(comments);
expect(result).toBe(
"[ghost at 2023-01-01T00:00:00Z]: From a deleted account",
);
});
test("returns empty string for empty comments array", () => { test("returns empty string for empty comments array", () => {
const result = formatComments([]); const result = formatComments([]);
expect(result).toBe(""); expect(result).toBe("");
@ -494,6 +527,29 @@ describe("formatReviewComments", () => {
); );
}); });
test("renders deleted (null-author) reviews as 'ghost'", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300099",
author: null,
body: "Left before deleting the account",
state: "COMMENTED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`,
);
});
test("formats multiple reviews correctly", () => { test("formats multiple reviews correctly", () => {
const reviewData = { const reviewData = {
nodes: [ nodes: [

View File

@ -484,4 +484,34 @@ describe("system_other handling", () => {
]); ]);
expect(markdown).toContain("## ⚙️ System Message"); expect(markdown).toContain("## ⚙️ System Message");
}); });
test("filters out thinking_tokens system messages", () => {
const data: Turn[] = [
{ type: "system", subtype: "init", tools: [{ name: "tool1" }] },
{ type: "system", subtype: "thinking_tokens" },
{ type: "system", subtype: "thinking_tokens" },
{ type: "system", subtype: "other_subtype" },
];
const grouped = groupTurnsNaturally(data);
// Should have init and other_subtype, but not thinking_tokens
expect(grouped).toHaveLength(2);
expect(grouped[0]?.type).toBe("system_init");
expect(grouped[1]?.type).toBe("system_other");
expect(grouped[1]?.data?.subtype).toBe("other_subtype");
});
test("thinking_tokens does not appear in formatted output", () => {
const data: Turn[] = [
{ type: "system", subtype: "init", tools: [] },
{ type: "system", subtype: "thinking_tokens" },
{ type: "system", subtype: "thinking_tokens" },
];
const result = formatTurnsFromData(data);
expect(result).not.toContain("thinking_tokens");
expect(result).toContain("## 🚀 System Initialization");
});
}); });

View File

@ -59,6 +59,22 @@ describe("stripMarkdownImageAltText", () => {
it("should handle empty alt text", () => { it("should handle empty alt text", () => {
expect(stripMarkdownImageAltText("![](image.png)")).toBe("![](image.png)"); expect(stripMarkdownImageAltText("![](image.png)")).toBe("![](image.png)");
}); });
it("should remove alt text from reference-style images", () => {
expect(stripMarkdownImageAltText("![example alt text][img1]")).toBe(
"![][img1]",
);
expect(
stripMarkdownImageAltText("Text ![description][ref] more text"),
).toBe("Text ![][ref] more text");
});
it("should preserve the reference label of a reference-style image", () => {
// the [ref] label must survive so the image definition still resolves;
// only the alt text (the injection channel) is removed
expect(stripMarkdownImageAltText("![alt][my-ref]")).toBe("![][my-ref]");
expect(stripMarkdownImageAltText("![][keep]")).toBe("![][keep]");
});
}); });
describe("stripMarkdownLinkTitles", () => { describe("stripMarkdownLinkTitles", () => {
@ -276,6 +292,16 @@ describe("redactGitHubTokens", () => {
); );
}); });
it("should redact user-to-server tokens (ghu_)", () => {
const token = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(`User token: ${token}`)).toBe(
"User token: [REDACTED_GITHUB_TOKEN]",
);
expect(
redactGitHubTokens(`In a URL: x-access-token:${token}@github.com`),
).toBe("In a URL: x-access-token:[REDACTED_GITHUB_TOKEN]@github.com");
});
it("should redact installation tokens (ghs_)", () => { it("should redact installation tokens (ghs_)", () => {
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"; const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
expect(redactGitHubTokens(`Install token: ${token}`)).toBe( expect(redactGitHubTokens(`Install token: ${token}`)).toBe(

View File

@ -74,6 +74,16 @@ describe("validateBranchName", () => {
expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow(); expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow();
expect(() => validateBranchName("agent/task@abc123")).not.toThrow(); expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
}); });
it("should accept branch names starting with underscore (git-valid, common for release branches)", () => {
// Leading underscores are valid per git check-ref-format and a common
// convention for release/internal branches. Rejecting them broke the
// action on any open PR whose base branch was e.g. "_release/v1.2.3",
// since setupBranch validates the PR's baseRefName after checkout.
expect(() => validateBranchName("_release/v1.2.3")).not.toThrow();
expect(() => validateBranchName("_internal")).not.toThrow();
expect(() => validateBranchName("_wip/feature-x")).not.toThrow();
});
}); });
describe("command injection attempts", () => { describe("command injection attempts", () => {