fix: skip workflow validation token exchange failures (#1417)

This commit is contained in:
Ryan Noonan 2026-06-17 13:53:35 -07:00 committed by GitHub
parent 9dd8b95a39
commit 0a08a86780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 214 additions and 20 deletions

View File

@ -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
View 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();
});
});