mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
fix: skip workflow validation token exchange failures (#1417)
This commit is contained in:
parent
9dd8b95a39
commit
0a08a86780
@ -10,6 +10,49 @@ export class WorkflowValidationSkipError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type AppTokenExchangeErrorResponse = {
|
||||
error?: {
|
||||
message?: string;
|
||||
details?: {
|
||||
error_code?: string;
|
||||
};
|
||||
};
|
||||
type?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const WORKFLOW_VALIDATION_ERROR_CODES = new Set([
|
||||
"workflow_not_found_on_default_branch",
|
||||
]);
|
||||
|
||||
function getAppTokenExchangeErrorMessage(
|
||||
responseJson: AppTokenExchangeErrorResponse,
|
||||
): string {
|
||||
return responseJson.error?.message ?? responseJson.message ?? "Unknown error";
|
||||
}
|
||||
|
||||
function isWorkflowValidationError(
|
||||
status: number,
|
||||
responseJson: AppTokenExchangeErrorResponse,
|
||||
): boolean {
|
||||
const errorCode = responseJson.error?.details?.error_code;
|
||||
if (
|
||||
errorCode !== undefined &&
|
||||
WORKFLOW_VALIDATION_ERROR_CODES.has(errorCode)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status !== 401) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workflowValidationMessage = "workflow validation failed";
|
||||
return [responseJson.message, responseJson.error?.message].some((message) =>
|
||||
message?.toLowerCase().includes(workflowValidationMessage),
|
||||
);
|
||||
}
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
@ -80,25 +123,11 @@ async function exchangeForAppToken(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseJson = (await response.json()) as {
|
||||
error?: {
|
||||
message?: string;
|
||||
details?: {
|
||||
error_code?: string;
|
||||
};
|
||||
};
|
||||
type?: string;
|
||||
message?: string;
|
||||
};
|
||||
const responseJson =
|
||||
(await response.json()) as AppTokenExchangeErrorResponse;
|
||||
|
||||
// Check for specific workflow validation error codes that should skip the action
|
||||
const errorCode = responseJson.error?.details?.error_code;
|
||||
|
||||
if (errorCode === "workflow_not_found_on_default_branch") {
|
||||
const message =
|
||||
responseJson.message ??
|
||||
responseJson.error?.message ??
|
||||
"Workflow validation failed";
|
||||
if (isWorkflowValidationError(response.status, responseJson)) {
|
||||
const message = getAppTokenExchangeErrorMessage(responseJson);
|
||||
core.warning(`Skipping action due to workflow validation: ${message}`);
|
||||
console.log(
|
||||
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
|
||||
@ -106,10 +135,11 @@ async function exchangeForAppToken(
|
||||
throw new WorkflowValidationSkipError(message);
|
||||
}
|
||||
|
||||
const message = getAppTokenExchangeErrorMessage(responseJson);
|
||||
console.error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${message}`,
|
||||
);
|
||||
throw new Error(`${responseJson?.error?.message ?? "Unknown error"}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const appTokenData = (await response.json()) as {
|
||||
|
||||
164
test/token.test.ts
Normal file
164
test/token.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import * as core from "@actions/core";
|
||||
import {
|
||||
setupGitHubToken,
|
||||
WorkflowValidationSkipError,
|
||||
} from "../src/github/token";
|
||||
|
||||
describe("setupGitHubToken", () => {
|
||||
let originalOverrideToken: string | undefined;
|
||||
let originalAdditionalPermissions: string | undefined;
|
||||
let getIDTokenSpy: any;
|
||||
let setSecretSpy: any;
|
||||
let warningSpy: any;
|
||||
let fetchSpy: any;
|
||||
let setTimeoutSpy: any;
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalOverrideToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
originalAdditionalPermissions = process.env.ADDITIONAL_PERMISSIONS;
|
||||
delete process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
|
||||
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue("oidc-token");
|
||||
setSecretSpy = spyOn(core, "setSecret").mockImplementation(() => {});
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ token: "app-token" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
}),
|
||||
);
|
||||
setTimeoutSpy = spyOn(global, "setTimeout").mockImplementation(((
|
||||
handler: any,
|
||||
) => {
|
||||
handler();
|
||||
return 0 as any;
|
||||
}) as any);
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOverrideToken === undefined) {
|
||||
delete process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.OVERRIDE_GITHUB_TOKEN = originalOverrideToken;
|
||||
}
|
||||
|
||||
if (originalAdditionalPermissions === undefined) {
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
} else {
|
||||
process.env.ADDITIONAL_PERMISSIONS = originalAdditionalPermissions;
|
||||
}
|
||||
|
||||
getIDTokenSpy.mockRestore();
|
||||
setSecretSpy.mockRestore();
|
||||
warningSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
setTimeoutSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns app token from OIDC exchange", async () => {
|
||||
await expect(setupGitHubToken()).resolves.toBe("app-token");
|
||||
|
||||
expect(getIDTokenSpy).toHaveBeenCalledWith("claude-code-github-action");
|
||||
expect(setSecretSpy).toHaveBeenCalledWith("app-token");
|
||||
});
|
||||
|
||||
test("skips without retrying when workflow is missing from default branch", async () => {
|
||||
const message =
|
||||
"Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch.";
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
details: {
|
||||
error_code: "workflow_not_found_on_default_branch",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(setupGitHubToken()).rejects.toBeInstanceOf(
|
||||
WorkflowValidationSkipError,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warningSpy).toHaveBeenCalledWith(
|
||||
`Skipping action due to workflow validation: ${message}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips without retrying when workflow validation message has no error code", async () => {
|
||||
const message =
|
||||
"Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch.";
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(setupGitHubToken()).rejects.toBeInstanceOf(
|
||||
WorkflowValidationSkipError,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warningSpy).toHaveBeenCalledWith(
|
||||
`Skipping action due to workflow validation: ${message}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("retries ordinary token exchange errors instead of skipping", async () => {
|
||||
const message = "Bad credentials";
|
||||
fetchSpy.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(setupGitHubToken()).rejects.toThrow(message);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
expect(warningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not skip message-only workflow validation errors with unexpected status", async () => {
|
||||
const message =
|
||||
"Workflow validation failed. The workflow file must exist and have identical content to the version on the repository's default branch.";
|
||||
fetchSpy.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message,
|
||||
},
|
||||
}),
|
||||
{ status: 500, statusText: "Internal Server Error" },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(setupGitHubToken()).rejects.toThrow(message);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
expect(warningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user