Compare commits

...

36 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
GitHub Actions
0fe28cdb64 chore: bump Claude Code to 2.1.203 and Agent SDK to 0.3.203 2026-07-07 21:07:00 +00:00
GitHub Actions
f87768c6d2 chore: bump Claude Code to 2.1.202 and Agent SDK to 0.3.202 2026-07-06 22:52:16 +00:00
Ashwin Bhat
58a2944bbc
chore: fix prettier formatting (#1463) 2026-07-04 09:31:09 -07:00
Akhilesh Arora
beb753ed72
fix: propagate curl failures in install pipeline (#1241)
installClaudeCode() pipes `curl -fsSL | bash -s --`. Bash exits with
the status of the last command, so when curl fails (429 rate limit,
403, or connection error) `bash -s` still exits 0 on empty stdin and
the action logs "Claude Code installed successfully". The 3-attempt
retry loop never triggers because the first attempt looks successful,
and the run later dies with "Executable not found in $PATH: claude".

Prefix the pipeline with `set -o pipefail;` so curl's non-zero exit
propagates through the pipe and the retry loop can actually kick in.
Extracted into buildInstallCommand() with regression tests covering
both the old buggy shape and the fixed one.
2026-07-03 22:38:53 -07:00
JerryLee
235b39bf21
fix: preserve repeated add-dir flags in claude args (#1256) 2026-07-03 22:38:30 -07:00
石岳峰
d060ddc963
fix(restore): handle symlinked CLAUDE.md paths during config snapshot (#1441)
When snapshotting PR-authored sensitive paths into .claude-pr/, cpSync with
dereference:true throws ENOENT if a symlink target is missing on the PR head
(e.g. .claude/CLAUDE.md -> ../AGENTS.md). Fall back to copying the symlink
itself so restoreConfigFromBase can continue and restore trusted base versions.

Fixes #1398
2026-07-03 22:38:23 -07:00
tarunag10
0f07aee435
Use modern noreply email for co-author trailers (#1369) 2026-07-03 22:38:02 -07:00
Archie
a221ad2dd9
Drop buffered inline comment when it is posted live (#1405) (#1412)
When classify_inline_comments is enabled, create_inline_comment buffers calls
without confirmed=true. The model frequently re-issues the call with confirmed=true
after reading the buffered reply, which posts the comment live but leaves the
original buffered entry behind. The post-session replay step then posts it again,
so every inline comment lands twice.

Reconcile the buffer on a live post: after a confirmed comment is created, remove
any buffered entry matching the same path, line, startLine and body so it cannot be
replayed. Extracts the reconciliation into src/mcp/inline-comment-buffer.ts (the MCP
server module starts a server on import) and adds unit tests.

Co-authored-by: archievi <13202986+archievi@users.noreply.github.com>
2026-07-03 22:37:36 -07:00
41 changed files with 1254 additions and 107 deletions

View File

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

View File

@ -6,7 +6,7 @@
"name": "@anthropic-ai/claude-code-base-action",
"dependencies": {
"@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.201",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"shell-quote": "^1.8.3",
},
"devDependencies": {
@ -27,23 +27,23 @@
"@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.201", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg=="],
"@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.201", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q=="],
"@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.201", "", { "os": "darwin", "cpu": "x64" }, "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ=="],
"@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.201", "", { "os": "linux", "cpu": "arm64" }, "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A=="],
"@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.201", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA=="],
"@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.201", "", { "os": "linux", "cpu": "x64" }, "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg=="],
"@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.201", "", { "os": "linux", "cpu": "x64" }, "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ=="],
"@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.201", "", { "os": "win32", "cpu": "arm64" }, "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg=="],
"@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.201", "", { "os": "win32", "cpu": "x64" }, "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA=="],
"@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=="],

View File

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

View File

@ -75,7 +75,8 @@ async function run() {
core.setOutput("conclusion", "failure");
process.exit(1);
} 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();
}
}

View File

@ -19,6 +19,7 @@ const ACCUMULATING_FLAGS = new Set([
"disallowedTools",
"disallowed-tools",
"mcp-config",
"add-dir",
]);
// Delimiter used to join accumulated flag values
@ -200,6 +201,17 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Detect if --json-schema is present (for hasJsonSchema flag)
const hasJsonSchema = "json-schema" in extraArgs;
const modelFromClaudeArgs = extraArgs["model"] || undefined;
delete extraArgs["model"];
const additionalDirectories = extraArgs["add-dir"]
? extraArgs["add-dir"]
.split(ACCUMULATE_DELIMITER)
.map((dir) => dir.trim())
.filter(Boolean)
: [];
delete extraArgs["add-dir"];
// Extract and merge allowedTools from all sources:
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
@ -295,7 +307,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Build SDK options - use merged tools from both direct options and claudeArgs
const sdkOptions: SdkOptions = {
// Direct options from ClaudeOptions inputs
model: options.model,
model: options.model || modelFromClaudeArgs,
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
allowedTools:
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
@ -304,6 +316,8 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
systemPrompt,
fallbackModel: options.fallbackModel,
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
additionalDirectories:
additionalDirectories.length > 0 ? additionalDirectories : undefined,
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates

View File

@ -208,7 +208,10 @@ export async function runClaudeWithSdk(
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";
// Handle structured output
@ -234,12 +237,19 @@ export async function runClaudeWithSdk(
}
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) {
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
}
throw new Error(
`Claude execution failed: ${
"errors" in resultMessage && resultMessage.errors
resultMessage.subtype === "success" && resultMessage.is_error
? "result is_error:true"
: "errors" in resultMessage && resultMessage.errors
? resultMessage.errors.join(", ")
: "unknown error"
}`,

View File

@ -15,7 +15,8 @@
*/
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 { retryWithBackoff } from "./retry";
@ -50,6 +51,63 @@ async function fetchIdentityToken(audience: string) {
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,
* 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
* 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<
WorkloadIdentityHandle | undefined
@ -101,6 +160,17 @@ export async function setupWorkloadIdentity(): Promise<
}
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(
`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 {
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);
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", () => {
@ -366,7 +367,8 @@ describe("parseSdkOptions", () => {
);
expect(mcpConfig.mcpServers).toHaveProperty("server1");
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", () => {
@ -402,6 +404,46 @@ describe("parseSdkOptions", () => {
});
});
describe("add-dir handling", () => {
test("should accumulate multiple add-dir flags into additionalDirectories", () => {
const options: ClaudeOptions = {
claudeArgs: '--add-dir "/path/to/dir-a"\n--add-dir "/path/to/dir-b"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.additionalDirectories).toEqual([
"/path/to/dir-a",
"/path/to/dir-b",
]);
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
});
test("should map a single add-dir flag to additionalDirectories", () => {
const options: ClaudeOptions = {
claudeArgs: '--add-dir "/path/to/dir"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
});
test("should preserve other extraArgs when extracting add-dir", () => {
const options: ClaudeOptions = {
claudeArgs: '--model "claude-3-5-sonnet" --add-dir "/path/to/dir"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
});
});
describe("other extraArgs passthrough", () => {
test("should pass through json-schema in extraArgs", () => {
const options: ClaudeOptions = {
@ -425,7 +467,8 @@ describe("parseSdkOptions", () => {
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"]);
});
@ -436,7 +479,8 @@ describe("parseSdkOptions", () => {
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", () => {
@ -446,11 +490,37 @@ describe("parseSdkOptions", () => {
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");
});
});
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", () => {
test("should include OTEL environment variables in sdkOptions.env", () => {
// Set up test environment variables

View File

@ -63,4 +63,69 @@ describe("runClaudeWithSdk", () => {
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 * 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 { join } from "path";
import {
@ -27,6 +34,12 @@ describe("workload identity federation", () => {
delete process.env.ANTHROPIC_ORGANIZATION_ID;
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
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(
"test-identity-token",
@ -123,5 +136,123 @@ describe("workload identity federation", () => {
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": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.201",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1",
@ -37,23 +37,23 @@
"@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.201", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg=="],
"@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.201", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q=="],
"@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.201", "", { "os": "darwin", "cpu": "x64" }, "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ=="],
"@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.201", "", { "os": "linux", "cpu": "arm64" }, "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A=="],
"@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.201", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA=="],
"@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.201", "", { "os": "linux", "cpu": "x64" }, "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg=="],
"@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.201", "", { "os": "linux", "cpu": "x64" }, "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ=="],
"@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.201", "", { "os": "win32", "cpu": "arm64" }, "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg=="],
"@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.201", "", { "os": "win32", "cpu": "x64" }, "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA=="],
"@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=="],

View File

@ -338,13 +338,13 @@ 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:
| Old Input | New Approach |
| --------------------- | -------------------------------------------------------- |
| --------------------- | --------------------------------------------------------------- |
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
| `max_turns` | Use `claude_args: "--max-turns 10"` |
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
| `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 '{...}'"` |
| `direct_prompt` | Use `prompt` input instead |
| `override_prompt` | Use `prompt` with GitHub context variables |

View File

@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
## 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
on:
@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is
## 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
on:

View File

@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities"
**These inputs are deprecated in v1.0:**
- **`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:
@ -165,7 +165,7 @@ custom_instructions: "Focus on security"
# New (v1.0)
prompt: "Review this PR"
claude_args: |
--system-prompt "Focus on security"
--append-system-prompt "Focus on security"
```
### Why doesn't Claude execute my bash commands?

View File

@ -15,11 +15,11 @@ 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:
| Deprecated Input | Replacement | Notes |
| --------------------- | ------------------------------------ | --------------------------------------------- |
| --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
| `mode` | Auto-detected | Action automatically chooses based on context |
| `direct_prompt` | `prompt` | Direct drop-in replacement |
| `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 |
| `model` | `claude_args: --model` | Specify via CLI |
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--max-turns 10
--system-prompt "Follow our coding standards"
--append-system-prompt "Follow our coding standards"
--allowedTools Edit,Read,Write
```
@ -256,12 +256,13 @@ claude_args: |
### Common claude_args Options
| Option | Description | Example |
| ------------------- | ------------------------ | -------------------------------------- |
| ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ |
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
| `--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"` |
| `--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
@ -330,7 +331,7 @@ You can also pass MCP configuration from a file:
- [ ] Remove `mode` input (auto-detected now)
- [ ] Replace `direct_prompt` with `prompt`
- [ ] 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 `model` to `claude_args` with `--model`
- [ ] 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 |
| `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 |
| `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"` |
| `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` |
@ -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 }}
claude_args: |
--max-turns 10
--system-prompt "Focus on security"
--append-system-prompt "Focus on security"
```
#### Automation Workflows

View File

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

View File

@ -122,6 +122,7 @@ export function prepareContext(
// Extract trigger username and comment data based on event type
let triggerUsername: string | undefined;
let triggerUserId: number | undefined;
let commentId: string | undefined;
let commentBody: string | undefined;
@ -129,15 +130,19 @@ export function prepareContext(
commentId = context.payload.comment.id.toString();
commentBody = context.payload.comment.body;
triggerUsername = context.payload.comment.user.login;
triggerUserId = context.payload.comment.user.id;
} else if (isPullRequestReviewEvent(context)) {
commentBody = context.payload.review.body ?? "";
triggerUsername = context.payload.review.user.login;
triggerUserId = context.payload.review.user.id;
} else if (isPullRequestReviewCommentEvent(context)) {
commentId = context.payload.comment.id.toString();
commentBody = context.payload.comment.body;
triggerUsername = context.payload.comment.user.login;
triggerUserId = context.payload.comment.user.id;
} else if (isIssuesEvent(context)) {
triggerUsername = context.payload.issue.user.login;
triggerUserId = context.payload.issue.user.id;
}
// Create infrastructure fields object
@ -146,6 +151,7 @@ export function prepareContext(
claudeCommentId,
triggerPhrase,
...(triggerUsername && { triggerUsername }),
...(triggerUserId && { triggerUserId }),
...(prompt && { prompt }),
...(claudeBranch && { claudeBranch }),
};
@ -394,9 +400,16 @@ function getCommitInstructions(
context: PreparedContext,
useCommitSigning: boolean,
): string {
const triggerName = githubData.triggerDisplayName ?? context.triggerUsername;
const triggerEmail =
context.triggerUserId && context.triggerUsername
? `${context.triggerUserId}+${context.triggerUsername}@users.noreply.github.com`
: context.triggerUsername
? `${context.triggerUsername}@users.noreply.github.com`
: undefined;
const coAuthorLine =
(githubData.triggerDisplayName ?? context.triggerUsername) !== "Unknown"
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
triggerName && triggerName !== "Unknown" && triggerEmail
? `Co-authored-by: ${triggerName} <${triggerEmail}>`
: "";
if (useCommitSigning) {

View File

@ -5,6 +5,7 @@ export type CommonFields = {
claudeCommentId: string;
triggerPhrase: string;
triggerUsername?: string;
triggerUserId?: number;
prompt?: string;
claudeBranch?: string;
};

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",
tools_count: tools.length,
});
} else {
} else if (subtype !== "thinking_tokens") {
// Skip thinking_tokens - internal progress events not meant for summary
groupedContent.push({
type: "system_other",
data: turn,

View File

@ -44,6 +44,13 @@ import { runClaude } from "../../base-action/src/run-claude";
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file";
// Exported for unit testing. `set -o pipefail` makes curl's non-zero exit
// propagate through the pipe so the install retry logic actually triggers
// on 429/403 instead of silently succeeding (see #1136).
export function buildInstallCommand(version: string): string {
return `set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s -- ${version}`;
}
/**
* Install Claude Code CLI, handling retry logic and custom executable paths.
* Returns the absolute path to the claude executable.
@ -68,7 +75,7 @@ async function installClaudeCode(): Promise<string> {
return customExecutable;
}
const claudeCodeVersion = "2.1.201";
const claudeCodeVersion = "2.1.220";
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
for (let attempt = 1; attempt <= 3; attempt++) {
@ -77,10 +84,7 @@ async function installClaudeCode(): Promise<string> {
await new Promise<void>((resolve, reject) => {
const child = spawn(
"bash",
[
"-c",
`curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`,
],
["-c", buildInstallCommand(claudeCodeVersion)],
{ stdio: "inherit" },
);
child.on("close", (code) => {
@ -314,7 +318,8 @@ async function run() {
} finally {
// 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();
// Update tracking comment

View File

@ -204,11 +204,9 @@ export function isBodySafeToUse(
* @param excludeActors - Comma-separated actors to exclude
* @returns Filtered array of comments
*/
export function filterCommentsByActor<T extends { author: { login: string } }>(
comments: T[],
includeActors: string = "",
excludeActors: string = "",
): T[] {
export function filterCommentsByActor<
T extends { author: { login: string } | null },
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
const includeParsed = parseActorFilter(includeActors);
const excludeParsed = parseActorFilter(excludeActors);
@ -219,7 +217,9 @@ export function filterCommentsByActor<T extends { author: { login: string } }>(
return comments.filter((comment) =>
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,
excludeParsed,
),

View File

@ -21,7 +21,7 @@ export function formatContext(
const prData = contextData as GitHubPullRequest;
const sanitizedTitle = sanitizeContent(prData.title);
return `PR Title: ${sanitizedTitle}
PR Author: ${prData.author.login}
PR Author: ${prData.author?.login ?? "ghost"}
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
PR State: ${prData.state}
PR Labels: ${formatLabels(prData.labels.nodes)}
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
const issueData = contextData as GitHubIssue;
const sanitizedTitle = sanitizeContent(issueData.title);
return `Issue Title: ${sanitizedTitle}
Issue Author: ${issueData.author.login}
Issue Author: ${issueData.author?.login ?? "ghost"}
Issue State: ${issueData.state}
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
}
@ -71,7 +71,7 @@ export function formatComments(
body = sanitizeContent(body);
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
})
.join("\n\n");
}
@ -85,7 +85,7 @@ export function formatReviewComments(
}
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()) {
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.
*
* 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 (@)
* - Do not start or end with a period
* - 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
// (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.
// _ 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.
const validPattern = /^[a-zA-Z0-9@][a-zA-Z0-9/_.#+,@-]*$/;
const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/;
if (!validPattern.test(branchName)) {
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

@ -30,6 +30,21 @@ const SENSITIVE_PATHS = [
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
function snapshotSensitivePath(src: string, dest: string): void {
try {
cpSync(src, dest, { recursive: true, dereference: true });
} catch (error) {
// Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md`
// -> `../AGENTS.md` when the PR deleted the target) make dereferenced
// copies throw ENOENT. Preserve the symlink for the review snapshot instead.
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
cpSync(src, dest, { recursive: true });
return;
}
throw error;
}
}
function ensureClaudePrExcludedFromGit(): void {
const excludePath = execFileSync(
"git",
@ -86,7 +101,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
rmSync(".claude-pr", { recursive: true, force: true });
for (const p of SENSITIVE_PATHS) {
if (existsSync(p)) {
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
snapshotSensitivePath(p, `.claude-pr/${p}`);
}
}
if (existsSync(".claude-pr")) {

View File

@ -1,4 +1,8 @@
// 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 = {
login: string;
name?: string;
@ -8,7 +12,7 @@ export type GitHubComment = {
id: string;
databaseId: string;
body: string;
author: GitHubAuthor;
author: GitHubAuthor | null;
createdAt: string;
updatedAt?: string;
lastEditedAt?: string;
@ -39,7 +43,7 @@ export type GitHubFile = {
export type GitHubReview = {
id: string;
databaseId: string;
author: GitHubAuthor;
author: GitHubAuthor | null;
body: string;
state: string;
submittedAt: string;
@ -53,7 +57,7 @@ export type GitHubReview = {
export type GitHubPullRequest = {
title: string;
body: string;
author: GitHubAuthor;
author: GitHubAuthor | null;
baseRefName: string;
headRefName: string;
headRefOid: string;
@ -95,7 +99,7 @@ export type GitHubPullRequest = {
export type GitHubIssue = {
title: string;
body: string;
author: GitHubAuthor;
author: GitHubAuthor | null;
createdAt: string;
updatedAt?: string;
lastEditedAt?: string;

View File

@ -10,7 +10,13 @@ export function stripInvisibleCharacters(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 {
@ -83,6 +89,12 @@ export function redactGitHubTokens(content: string): string {
"[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)
content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g,

View File

@ -5,6 +5,7 @@ import { appendFileSync } from "fs";
import { z } from "zod";
import { createOctokit } from "../github/api/client";
import { sanitizeContent } from "../github/utils/sanitizer";
import { removeBufferedComment } from "./inline-comment-buffer";
// Get repository and PR information from environment variables
const REPO_OWNER = process.env.REPO_OWNER;
@ -180,6 +181,16 @@ server.tool(
const result = await octokit.rest.pulls.createReviewComment(params);
// The comment is now live. Drop any buffered copy of it so the
// post-session replay step cannot post it a second time (the model often
// re-issues a buffered call with confirmed=true after the buffer reply).
if (CLASSIFY_ENABLED) {
removeBufferedComment(
{ path, line, startLine, body: sanitizedBody },
BUFFER_PATH,
);
}
return {
content: [
{

View File

@ -0,0 +1,54 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
export type BufferedCommentMatch = {
path: string;
line?: number;
startLine?: number;
body: string;
};
/**
* Remove any buffered inline comment that matches an already-posted comment.
*
* When a comment is posted live (confirmed=true), an earlier buffered copy of
* the same comment must be dropped so the post-session replay step does not
* post it a second time. The model frequently re-issues a buffered call with
* confirmed=true after reading the "Set confirmed=true to post immediately"
* reply; previously the original buffered entry was left behind and replayed,
* producing duplicate inline comments.
*
* Entries are matched on path, line, startLine and body. Lines that cannot be
* parsed are kept untouched.
*/
export function removeBufferedComment(
match: BufferedCommentMatch,
bufferPath: string,
): void {
if (!existsSync(bufferPath)) {
return;
}
const remaining = readFileSync(bufferPath, "utf8")
.split("\n")
.filter((line) => line.trim() !== "")
.filter((line) => {
let entry: BufferedCommentMatch;
try {
entry = JSON.parse(line);
} catch {
// Keep anything we cannot parse rather than silently dropping it.
return true;
}
const isSameComment =
entry.path === match.path &&
entry.line === match.line &&
entry.startLine === match.startLine &&
entry.body === match.body;
return !isSameComment;
});
writeFileSync(
bufferPath,
remaining.length > 0 ? remaining.join("\n") + "\n" : "",
);
}

View File

@ -28,6 +28,20 @@ function extractDescription(
.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 {
prefix: string;
entityType: string;
@ -78,7 +92,7 @@ export function generateBranchName(
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")}`,
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,
};

View File

@ -5,6 +5,7 @@ import {
applyBranchTemplate,
generateBranchName,
} from "../src/utils/branch-template";
import { validateBranchName } from "../src/github/operations/branch";
describe("branch template utilities", () => {
describe("applyBranchTemplate", () => {
@ -144,6 +145,53 @@ describe("branch template utilities", () => {
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", () => {
const template = "{{prefix}}{{description}}/{{entityNumber}}";
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,
buildAllowedToolsString,
buildDisallowedToolsString,
prepareContext,
} from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt";
import { createMockContext } from "./mockContext";
beforeAll(() => {
process.env.GITHUB_ACTION_PATH = "/test/action/path";
@ -495,6 +497,32 @@ describe("generatePrompt", () => {
);
});
test("should use numeric GitHub noreply address when trigger user id is provided", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
triggerUsername: "johndoe",
triggerUserId: 123456,
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: false,
issueNumber: "123",
baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101-1200",
commentBody: "@claude please fix this",
},
};
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
expect(prompt).toContain(
"Co-authored-by: johndoe <123456+johndoe@users.noreply.github.com>",
);
expect(prompt).not.toContain("<johndoe@users.noreply.github.com>");
});
test("should include PR-specific instructions only for PR events", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
@ -1244,3 +1272,83 @@ describe("buildDisallowedToolsString", () => {
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", "");
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`,
);
});
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", () => {
@ -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", () => {
const result = formatComments([]);
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", () => {
const reviewData = {
nodes: [

View File

@ -484,4 +484,34 @@ describe("system_other handling", () => {
]);
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

@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { removeBufferedComment } from "../src/mcp/inline-comment-buffer";
describe("removeBufferedComment", () => {
let dir: string;
let bufferPath: string;
const entryA = {
ts: "2026-06-13T00:00:00.000Z",
path: "src/index.ts",
line: 10,
startLine: undefined,
side: "RIGHT",
body: "Comment A",
};
const entryB = {
ts: "2026-06-13T00:00:01.000Z",
path: "src/other.ts",
line: 20,
startLine: undefined,
side: "RIGHT",
body: "Comment B",
};
const writeBuffer = (entries: object[]): void => {
writeFileSync(
bufferPath,
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
);
};
const readBuffer = (): Array<{ body: string }> => {
if (!existsSync(bufferPath)) {
return [];
}
return readFileSync(bufferPath, "utf8")
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => JSON.parse(line));
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "inline-buffer-"));
bufferPath = join(dir, "buffer.jsonl");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("removes the matching buffered entry and keeps the others", () => {
writeBuffer([entryA, entryB]);
removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);
const remaining = readBuffer();
expect(remaining.map((e) => e.body)).toEqual(["Comment B"]);
});
it("removes every copy when the same comment was buffered more than once", () => {
writeBuffer([entryA, entryA, entryB]);
removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);
expect(readBuffer().map((e) => e.body)).toEqual(["Comment B"]);
});
it("leaves the buffer untouched when nothing matches", () => {
writeBuffer([entryA, entryB]);
removeBufferedComment(
{
path: "src/index.ts",
line: 999,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);
expect(readBuffer().map((e) => e.body)).toEqual(["Comment A", "Comment B"]);
});
it("does nothing when the buffer file does not exist", () => {
expect(() =>
removeBufferedComment(
{ path: "src/index.ts", line: 10, body: "Comment A" },
bufferPath,
),
).not.toThrow();
expect(existsSync(bufferPath)).toBe(false);
});
it("keeps lines that cannot be parsed as JSON", () => {
writeFileSync(
bufferPath,
["not json", JSON.stringify(entryA)].join("\n") + "\n",
);
removeBufferedComment(
{
path: "src/index.ts",
line: 10,
startLine: undefined,
body: "Comment A",
},
bufferPath,
);
const raw = readFileSync(bufferPath, "utf8");
expect(raw).toContain("not json");
expect(raw).not.toContain("Comment A");
});
});

View File

@ -0,0 +1,50 @@
import { describe, it, expect } from "bun:test";
import { spawnSync } from "child_process";
import { buildInstallCommand } from "../src/entrypoints/run";
describe("buildInstallCommand (regression for #1136)", () => {
it("includes the pinned claude version in the bash -s args", () => {
const cmd = buildInstallCommand("2.1.114");
expect(cmd).toContain("bash -s -- 2.1.114");
});
it("prefixes the pipeline with `set -o pipefail`", () => {
const cmd = buildInstallCommand("2.1.114");
expect(cmd.startsWith("set -o pipefail;")).toBe(true);
});
it("keeps the curl -fsSL flags so the script is fetched, not inlined", () => {
const cmd = buildInstallCommand("2.1.114");
expect(cmd).toContain(
"curl -fsSL https://claude.ai/install.sh | bash -s --",
);
});
});
describe("pipefail semantics (proves the bug shape and the fix)", () => {
// Mirrors the real install invocation: a curl that returns non-zero
// feeding into `bash -s --`. Without pipefail, the pipeline exits 0
// because bash -s receives an empty stdin and does nothing. With
// pipefail, curl's exit code wins and the retry loop in run.ts triggers.
//
// Uses port 1 (reserved/unused) so curl fails deterministically with no
// network access. No shell-escaping traps here: the version argument is
// a numeric literal.
const unreachable = "http://127.0.0.1:1/nope";
const version = "2.1.114";
it("BEFORE FIX: pipeline without pipefail swallows curl failure (exit 0)", () => {
const buggy = `curl -fsSL ${unreachable} | bash -s -- ${version}`;
const result = spawnSync("bash", ["-c", buggy], { stdio: "pipe" });
expect(result.status).toBe(0);
});
it("AFTER FIX: buildInstallCommand (against unreachable host) exits non-zero", () => {
const fixed = buildInstallCommand(version).replace(
"https://claude.ai/install.sh",
unreachable,
);
const result = spawnSync("bash", ["-c", fixed], { stdio: "pipe" });
expect(result.status).not.toBe(0);
});
});

View File

@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "child_process";
import {
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "fs";
import { dirname, isAbsolute, join } from "path";
@ -121,6 +123,48 @@ describe("restoreConfigFromBase", () => {
}
});
test("restores symlinked CLAUDE.md paths from the PR base branch", () => {
setupSymlinkedMainBranch();
git(["checkout", "pr"]);
writeRepoFile(
".claude/settings.json",
`${JSON.stringify({ source: "pr-with-symlinks" })}\n`,
);
git(["add", ".claude/settings.json"]);
git(["commit", "-m", "pr updates settings"]);
restoreConfigFromBase("main");
expect(lstatRepoFile("CLAUDE.md").isSymbolicLink()).toBe(true);
expect(lstatRepoFile(".claude/CLAUDE.md").isSymbolicLink()).toBe(true);
expect(readRepoFile("CLAUDE.md").trim()).toBe("shared agent instructions");
expect(readRepoFile(".claude/CLAUDE.md").trim()).toBe(
"shared agent instructions",
);
expect(readRepoFile(".claude/settings.json")).toBe(
`${JSON.stringify({ source: "base" })}\n`,
);
});
test("snapshots symlinked sensitive paths even when the PR head target is missing", () => {
setupSymlinkedMainBranch();
git(["checkout", "pr"]);
rmSync(join(repoDir, "AGENTS.md"), { force: true });
git(["add", "-A"]);
git(["commit", "-m", "pr deletes agents file"]);
restoreConfigFromBase("main");
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe(
true,
);
expect(readRepoFile(".claude/settings.json")).toBe(
`${JSON.stringify({ source: "base" })}\n`,
);
});
test("does not modify an existing .gitignore", () => {
writeRepoFile(".gitignore", "node_modules\n");
git(["add", ".gitignore"]);
@ -156,6 +200,29 @@ describe("restoreConfigFromBase", () => {
return existsSync(join(repoDir, path));
}
function symlinkRepoFile(path: string, target: string): void {
const fullPath = join(repoDir, path);
mkdirSync(dirname(fullPath), { recursive: true });
symlinkSync(target, fullPath);
}
function lstatRepoFile(path: string) {
return lstatSync(join(repoDir, path));
}
function setupSymlinkedMainBranch(): void {
git(["checkout", "main"]);
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
writeRepoFile("AGENTS.md", "shared agent instructions\n");
symlinkRepoFile("CLAUDE.md", "AGENTS.md");
symlinkRepoFile(".claude/CLAUDE.md", "../AGENTS.md");
git(["add", "AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"]);
git(["commit", "-m", "add symlinked claude files"]);
git(["push", "origin", "main"]);
git(["branch", "-D", "pr"]);
git(["checkout", "-b", "pr"]);
}
function countClaudePrExcludeEntries(): number {
return readFileSync(getExcludePath(), "utf8")
.split(/\r?\n/)

View File

@ -59,6 +59,22 @@ describe("stripMarkdownImageAltText", () => {
it("should handle empty alt text", () => {
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", () => {
@ -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_)", () => {
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(

View File

@ -74,6 +74,16 @@ describe("validateBranchName", () => {
expect(() => validateBranchName("@hotfix/login-timeout")).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", () => {