Compare commits

..

No commits in common. "main" and "v1.0.173" have entirely different histories.

31 changed files with 97 additions and 739 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.220" CLAUDE_CODE_VERSION="2.1.209"
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.220", "@anthropic-ai/claude-agent-sdk": "^0.3.209",
"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.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": ["@anthropic-ai/claude-agent-sdk@0.3.209", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.209", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.209" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-HdxLmpnIOd4CSYxdlD/4ShKFvHAuWefP1MTf/B+Mj0Oq8Tb9qzmhLuHCZMd5Pf21Knrt7jke/t4coYAtokFcuw=="],
"@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-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.209", "", { "os": "darwin", "cpu": "arm64" }, "sha512-08qu5ZImKxAsfyxQdXw2fHpIvTS39kAN/T6iWdRPY/1yicnqAxIl8R9PQ63TJaDnWTo7wWu6O26GKbEX9d7xDw=="],
"@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-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.209", "", { "os": "darwin", "cpu": "x64" }, "sha512-p3egSQ75amhjOO0P21pNfs94SueEAbOc0s7IFrGy8CZKXcFMIrOzUXKoi2iTWtR8P9v4ayibf38AJRa2Rz7KPA=="],
"@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": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.209", "", { "os": "linux", "cpu": "arm64" }, "sha512-eTgga/TuganOj9VAWUrjtt6BSqapeJau4ioCrQRw0RbDN/d/oLih/hdKzPeCn3yBw4Wdwwlip2z1cHKPNEggtQ=="],
"@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-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.209", "", { "os": "linux", "cpu": "arm64" }, "sha512-jtoia0l10xgrsBWjA12n6Q0WxOyr/8qf1eUMOGSO7zdNau/7wABYBXS+VGF8utBEsF4NJjfCEEj2yhEB8uHjLA=="],
"@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": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.209", "", { "os": "linux", "cpu": "x64" }, "sha512-XGhc23qmqk1yUwG633iZUxPM91iJjrX/a+DVy+Pz5X5J64aXFAsR9R+kNkJ1X5FDcm+MSYywUtQm9AmAZaiG2Q=="],
"@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-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.209", "", { "os": "linux", "cpu": "x64" }, "sha512-GjxSlqk6yYCxb9BApVZvJ/Aq3OoI5mDRhpBpsZM23idNHg5/019sBBx4IGmkBjmfxTX5zzvu+m0fU18vl3W2Mg=="],
"@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-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.209", "", { "os": "win32", "cpu": "arm64" }, "sha512-LIMVnToVRmcTeN/dDAdFP/U60/aM0/qbKOWza+0BabhrGwF/3nPIIoGrMrkLsKPzOsfNWrl+kepexP1zWb1x5w=="],
"@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/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.209", "", { "os": "win32", "cpu": "x64" }, "sha512-tY5/+lmhqm7JQhvxLVTkhe2/UAw46gu8RnFrTwhoM0gEXDTmQhNlTM+m4F9LGeSfgKb2TElHpcQ237XNG1Ok6Q=="],
"@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.220", "@anthropic-ai/claude-agent-sdk": "^0.3.209",
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -75,8 +75,7 @@ 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 // Stop refreshing the workload identity token file so the process can exit
// exit) and delete the token material so it doesn't outlive this step
workloadIdentity?.stop(); workloadIdentity?.stop();
} }
} }

View File

@ -201,9 +201,6 @@ 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)
@ -307,7 +304,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 || modelFromClaudeArgs, model: options.model,
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

@ -15,8 +15,7 @@
*/ */
import * as core from "@actions/core"; import * as core from "@actions/core";
import { createHash } from "crypto"; import { mkdirSync, writeFileSync } from "fs";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { retryWithBackoff } from "./retry"; import { retryWithBackoff } from "./retry";
@ -51,63 +50,6 @@ 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
@ -115,8 +57,7 @@ function writeFederationProfile(baseDir: string): 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; it also deletes the identity token and any cached exchanged * finishes.
* credential.
*/ */
export async function setupWorkloadIdentity(): Promise< export async function setupWorkloadIdentity(): Promise<
WorkloadIdentityHandle | undefined WorkloadIdentityHandle | undefined
@ -160,17 +101,6 @@ 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})`,
); );
@ -185,12 +115,6 @@ export async function setupWorkloadIdentity(): Promise<
return { return {
tokenFile, tokenFile,
stop: () => { stop: () => clearInterval(refreshInterval),
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,8 +106,7 @@ 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"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
}); });
test("should handle hyphenated --allowed-tools flag", () => { test("should handle hyphenated --allowed-tools flag", () => {
@ -367,8 +366,7 @@ 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"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
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", () => {
@ -438,8 +436,7 @@ 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"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
}); });
}); });
@ -467,8 +464,7 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
expect(result.sdkOptions.model).toBe("claude-haiku");
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]); expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
}); });
@ -479,8 +475,7 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
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", () => {
@ -490,37 +485,11 @@ describe("parseSdkOptions", () => {
const result = parseSdkOptions(options); const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined(); expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
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

@ -2,14 +2,7 @@
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 { import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
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 {
@ -34,12 +27,6 @@ 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",
@ -136,123 +123,5 @@ 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.220", "@anthropic-ai/claude-agent-sdk": "^0.3.209",
"@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.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": ["@anthropic-ai/claude-agent-sdk@0.3.209", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.209", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.209", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.209", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.209" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-HdxLmpnIOd4CSYxdlD/4ShKFvHAuWefP1MTf/B+Mj0Oq8Tb9qzmhLuHCZMd5Pf21Knrt7jke/t4coYAtokFcuw=="],
"@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-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.209", "", { "os": "darwin", "cpu": "arm64" }, "sha512-08qu5ZImKxAsfyxQdXw2fHpIvTS39kAN/T6iWdRPY/1yicnqAxIl8R9PQ63TJaDnWTo7wWu6O26GKbEX9d7xDw=="],
"@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-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.209", "", { "os": "darwin", "cpu": "x64" }, "sha512-p3egSQ75amhjOO0P21pNfs94SueEAbOc0s7IFrGy8CZKXcFMIrOzUXKoi2iTWtR8P9v4ayibf38AJRa2Rz7KPA=="],
"@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": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.209", "", { "os": "linux", "cpu": "arm64" }, "sha512-eTgga/TuganOj9VAWUrjtt6BSqapeJau4ioCrQRw0RbDN/d/oLih/hdKzPeCn3yBw4Wdwwlip2z1cHKPNEggtQ=="],
"@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-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.209", "", { "os": "linux", "cpu": "arm64" }, "sha512-jtoia0l10xgrsBWjA12n6Q0WxOyr/8qf1eUMOGSO7zdNau/7wABYBXS+VGF8utBEsF4NJjfCEEj2yhEB8uHjLA=="],
"@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": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.209", "", { "os": "linux", "cpu": "x64" }, "sha512-XGhc23qmqk1yUwG633iZUxPM91iJjrX/a+DVy+Pz5X5J64aXFAsR9R+kNkJ1X5FDcm+MSYywUtQm9AmAZaiG2Q=="],
"@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-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.209", "", { "os": "linux", "cpu": "x64" }, "sha512-GjxSlqk6yYCxb9BApVZvJ/Aq3OoI5mDRhpBpsZM23idNHg5/019sBBx4IGmkBjmfxTX5zzvu+m0fU18vl3W2Mg=="],
"@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-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.209", "", { "os": "win32", "cpu": "arm64" }, "sha512-LIMVnToVRmcTeN/dDAdFP/U60/aM0/qbKOWza+0BabhrGwF/3nPIIoGrMrkLsKPzOsfNWrl+kepexP1zWb1x5w=="],
"@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/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.209", "", { "os": "win32", "cpu": "x64" }, "sha512-tY5/+lmhqm7JQhvxLVTkhe2/UAw46gu8RnFrTwhoM0gEXDTmQhNlTM+m4F9LGeSfgKb2TElHpcQ237XNG1Ok6Q=="],
"@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

@ -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: 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: "--append-system-prompt 'Your instructions'"` | | `custom_instructions` | Use `claude_args: "--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 |

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/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)): Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.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/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)): Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.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 `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely) - **`custom_instructions`** → Use `claude_args` with `--system-prompt`
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: |
--append-system-prompt "Focus on security" --system-prompt "Focus on security"
``` ```
### Why doesn't Claude execute my bash commands? ### 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: 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: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely | | `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments |
| `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 |
@ -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
--append-system-prompt "Follow our coding standards" --system-prompt "Follow our coding standards"
--allowedTools Edit,Read,Write --allowedTools Edit,Read,Write
``` ```
@ -256,13 +256,12 @@ 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` | Replace the entire default system prompt | `--system-prompt "Focus on security"` | | `--system-prompt` | Add system instructions | `--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": {...}}'` | | `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
## Provider-Specific Updates ## Provider-Specific Updates
@ -331,7 +330,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 `--append-system-prompt` - [ ] Move `custom_instructions` to `claude_args` with `--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 `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` | | `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--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
--append-system-prompt "Focus on security" --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.220", "@anthropic-ai/claude-agent-sdk": "^0.3.209",
"@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 Normal file → Executable file
View File

@ -268,8 +268,7 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
type: "system_init", type: "system_init",
tools_count: tools.length, tools_count: tools.length,
}); });
} else if (subtype !== "thinking_tokens") { } else {
// 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.220"; const claudeCodeVersion = "2.1.209";
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,8 +318,7 @@ async function run() {
} finally { } finally {
// Phase 4: Cleanup (always runs) // Phase 4: Cleanup (always runs)
// Stop refreshing the workload identity token file and delete the token // Stop refreshing the workload identity token file
// material so it doesn't outlive this step
workloadIdentity?.stop(); workloadIdentity?.stop();
// Update tracking comment // Update tracking comment

View File

@ -204,9 +204,11 @@ 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< export function filterCommentsByActor<T extends { author: { login: string } }>(
T extends { author: { login: string } | null }, comments: T[],
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] { includeActors: string = "",
excludeActors: string = "",
): T[] {
const includeParsed = parseActorFilter(includeActors); const includeParsed = parseActorFilter(includeActors);
const excludeParsed = parseActorFilter(excludeActors); const excludeParsed = parseActorFilter(excludeActors);
@ -217,9 +219,7 @@ export function filterCommentsByActor<
return comments.filter((comment) => return comments.filter((comment) =>
shouldIncludeCommentByActor( shouldIncludeCommentByActor(
// author is null for comments from deleted ("ghost") accounts; treat them comment.author.login,
// 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 ?? "ghost"} PR Author: ${prData.author.login}
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 ?? "ghost"} Issue Author: ${issueData.author.login}
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 ?? "ghost"} at ${comment.createdAt}]: ${body}`; return `[${comment.author.login} 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 ?? "ghost"} at ${review.submittedAt}]: ${review.state}`; let reviewOutput = `[Review by ${review.author.login} 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, underscore, or @ (not dash, to prevent option injection) * - Start with alphanumeric character 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,15 +68,12 @@ 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, underscore, 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 or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
); );
} }

View File

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

View File

@ -10,13 +10,7 @@ export function stripInvisibleCharacters(content: string): string {
} }
export function stripMarkdownImageAltText(content: string): string { export function stripMarkdownImageAltText(content: string): string {
// Inline images: ![alt](url) -> ![](url) return content.replace(/!\[[^\]]*\]\(/g, "![](");
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 {
@ -89,12 +83,6 @@ 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,20 +28,6 @@ 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;
@ -92,7 +78,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 && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label label: label || entityType, // Fall back to entityType if no label
description: title ? extractDescription(title) : undefined, description: title ? extractDescription(title) : undefined,
}; };

View File

@ -5,7 +5,6 @@ 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", () => {
@ -145,53 +144,6 @@ 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

@ -1,74 +0,0 @@
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,10 +6,8 @@ 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";
@ -1272,83 +1270,3 @@ 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,42 +1499,4 @@ 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,21 +159,6 @@ 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", () => {
@ -267,24 +252,6 @@ 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("");
@ -527,29 +494,6 @@ 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,34 +484,4 @@ 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,22 +59,6 @@ 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", () => {
@ -292,16 +276,6 @@ 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,16 +74,6 @@ 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", () => {