feat: integrate OIDC token exchange in GitHub Action

- Add setupGitHubInstallationToken utility for OIDC token generation
- Implement automatic token exchange with Pullfrog API endpoint
- Add support for multiple authentication methods (input, env, OIDC)
- Create setup utilities for test repository management
- Update action entry point to handle new token flow
- Add environment variable documentation for API key
- Remove large bundled dependencies and optimize build
- Support both development and production token workflows
This commit is contained in:
Colin McDonnell
2025-09-10 00:30:45 -07:00
parent c5b9c7cfc4
commit 3139f541e4
14 changed files with 719 additions and 26533 deletions
+8
View File
@@ -55,6 +55,14 @@ pnpm dev # Watch mode
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules. The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
## Environment Variables
Create `.env` in `/action`:
```bash
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Claude API key
```
## Architecture ## Architecture
- **entry.cjs**: Bundled action entry point (self-contained) - **entry.cjs**: Bundled action entry point (self-contained)
+6
View File
@@ -10,6 +10,12 @@ inputs:
anthropic_api_key: anthropic_api_key:
description: "Anthropic API key for Claude Code authentication" description: "Anthropic API key for Claude Code authentication"
required: false required: false
github_token:
description: "GitHub token for repository access"
required: false
github_installation_token:
description: "GitHub App installation token"
required: false
runs: runs:
using: "node20" using: "node20"
+456 -407
View File
File diff suppressed because one or more lines are too long
+28 -16
View File
@@ -7,43 +7,55 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { main } from "./main"; import { main } from "./main";
import { setupGitHubInstallationToken } from "./utils";
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
// Get inputs from GitHub Actions // Get inputs from GitHub Actions
const prompt = core.getInput("prompt", { required: true }); const prompt = core.getInput("prompt", { required: true });
const anthropicApiKey = core.getInput("anthropic_api_key", { const anthropicApiKey = core.getInput("anthropic_api_key");
required: true,
});
if (!anthropicApiKey) {
throw new Error("anthropic_api_key is required");
}
if (!prompt) { if (!prompt) {
throw new Error("prompt is required"); throw new Error("prompt is required");
} }
// Create params object // Create params object with new structure
const params = { const inputs: any = {
prompt, prompt,
anthropicApiKey, anthropic_api_key: anthropicApiKey,
};
// Add optional properties only if they exist
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN;
if (githubToken) {
inputs.github_token = githubToken;
}
const githubInstallationToken =
core.getInput("github_installation_token") || process.env.GITHUB_INSTALLATION_TOKEN;
if (githubInstallationToken) {
inputs.github_installation_token = githubInstallationToken;
} else {
// Setup GitHub installation token
await setupGitHubInstallationToken();
}
const params = {
inputs,
env: {} as Record<string, string>,
cwd: process.cwd(),
}; };
// Run the main function // Run the main function
const result = await main(params); const result = await main(params);
// Set outputs // TODO: Set outputs
core.setOutput("status", result.success ? "success" : "failed");
core.setOutput("prompt", prompt);
core.setOutput("output", result.output || "");
if (!result.success) { if (!result.success) {
throw new Error(result.error || "Agent execution failed"); throw new Error(result.error || "Agent execution failed");
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`); core.setFailed(`Action failed: ${errorMessage}`);
} }
} }
+7 -1
View File
@@ -1,7 +1,13 @@
import type { MainParams } from "../main"; import type { MainParams } from "../main";
const testParams = { const testParams = {
prompt: "List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it." inputs: {
prompt:
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
anthropic_api_key: "sk-test-key",
},
env: {},
cwd: process.cwd(),
} satisfies MainParams; } satisfies MainParams;
export default testParams; export default testParams;
-25978
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -3,6 +3,11 @@
* This exports the main function for programmatic usage * This exports the main function for programmatic usage
*/ */
export { main } from "./main";
export { ClaudeAgent } from "./agents"; export { ClaudeAgent } from "./agents";
export type { Agent, AgentConfig, AgentResult } from "./agents/types"; export type { Agent, AgentConfig, AgentResult } from "./agents/types";
export {
type ExecutionInputs,
type MainParams,
type MainResult,
main,
} from "./main";
+29 -11
View File
@@ -1,9 +1,24 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { ClaudeAgent } from "./agents"; import { ClaudeAgent } from "./agents";
export interface MainParams { // Expected environment variables that should be passed as inputs
export const EXPECTED_INPUTS: string[] = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
"GITHUB_INSTALLATION_TOKEN"
];
export interface ExecutionInputs {
prompt: string; prompt: string;
anthropicApiKey?: string; anthropic_api_key: string;
github_token?: string;
github_installation_token?: string;
}
export interface MainParams {
inputs: ExecutionInputs;
env: Record<string, string>;
cwd: string;
} }
export interface MainResult { export interface MainResult {
@@ -12,24 +27,28 @@ export interface MainResult {
error?: string | undefined; error?: string | undefined;
} }
export async function main(params: MainParams): Promise<MainResult> { export async function main(params: MainParams): Promise<MainResult> {
try { try {
// Use provided API key or fall back to environment variable // Extract inputs from params
const anthropicApiKey = const { inputs, env, cwd } = params;
params.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
if (!anthropicApiKey) { // Set working directory if different from current
throw new Error("anthropic_api_key is required"); if (cwd !== process.cwd()) {
process.chdir(cwd);
} }
// Set environment variables
Object.assign(process.env, env);
core.info(`→ Starting agent run with Claude Code`); core.info(`→ Starting agent run with Claude Code`);
// Create and install the Claude agent // Create and install the Claude agent
const agent = new ClaudeAgent({ apiKey: anthropicApiKey }); const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key });
await agent.install(); await agent.install();
// Execute the agent with the prompt // Execute the agent with the prompt
const result = await agent.execute(params.prompt); const result = await agent.execute(inputs.prompt);
if (!result.success) { if (!result.success) {
return { return {
@@ -44,8 +63,7 @@ export async function main(params: MainParams): Promise<MainResult> {
output: result.output || "", output: result.output || "",
}; };
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
error instanceof Error ? error.message : "Unknown error occurred";
return { return {
success: false, success: false,
error: errorMessage, error: errorMessage,
+4 -3
View File
@@ -17,21 +17,22 @@
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js", "build": "node esbuild.config.js",
"build:npm": "zshy", "build:npm": "zshy",
"build:dev": "node esbuild.config.js", "build:dev": "node esbuild.config.js",
"prepare": "husky", "prepare": "husky",
"play": "tsx --env-file=../.env play.ts" "play": "tsx play.ts"
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.11.1",
"dotenv": "^17.2.2",
"execa": "^9.6.0", "execa": "^9.6.0",
"table": "^6.9.0" "table": "^6.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"commander": "^14.0.0", "commander": "^14.0.0",
"dotenv": "^17.2.2",
"esbuild": "^0.25.9", "esbuild": "^0.25.9",
"husky": "^9.0.0", "husky": "^9.0.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
+44 -75
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env tsx import { existsSync, readFileSync } from "node:fs";
import { dirname, extname, join, resolve } from "node:path";
import { execSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve, extname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { config } from "dotenv";
import { main } from "./main"; import { main } from "./main";
import { runAct } from "./utils/act"; import { runAct } from "./utils/act";
import { setupTestRepo } from "./utils/setup";
// Load environment variables from .env file
config();
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -46,9 +49,7 @@ async function loadPrompt(filePath: string): Promise<string> {
const module = await import(fileUrl); const module = await import(fileUrl);
if (!module.default) { if (!module.default) {
throw new Error( throw new Error(`TypeScript file ${filePath} must have a default export`);
`TypeScript file ${filePath} must have a default export`,
);
} }
// If it's a string, use it directly // If it's a string, use it directly
@@ -66,16 +67,11 @@ async function loadPrompt(filePath: string): Promise<string> {
} }
default: default:
throw new Error( throw new Error(`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`);
`Unsupported file type: ${ext}. Supported types: .txt, .json, .ts`,
);
} }
} }
async function runPlay( async function runPlay(filePath: string, options: { act?: boolean }): Promise<void> {
filePath: string,
options: { act?: boolean },
): Promise<void> {
try { try {
// Load the prompt from the specified file // Load the prompt from the specified file
const prompt = await loadPrompt(filePath); const prompt = await loadPrompt(filePath);
@@ -85,56 +81,9 @@ async function runPlay(
console.log("🐳 Running with Docker/act..."); console.log("🐳 Running with Docker/act...");
runAct(prompt); runAct(prompt);
} else { } else {
// Clone the test repository and run directly // Setup test repository and run directly
const tempDir = join(process.cwd(), ".temp"); const tempDir = join(process.cwd(), ".temp");
const repoUrl = "git@github.com:pullfrogai/scratch.git"; setupTestRepo({ tempDir, forceClean: true });
// Remove existing temp directory if it exists
if (existsSync(tempDir)) {
console.log("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
}
// Clone the repository
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
// List of environment variables to copy to .temp
const envVarsToCopy = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
// Add more environment variables here as needed
];
// Build .env content from the list
const envLines = envVarsToCopy
.map((varName) => `${varName}=${process.env[varName] || ""}`)
.join("\n");
const envPath = join(tempDir, ".env");
writeFileSync(envPath, envLines + "\n");
console.log("📝 Created .env file in .temp directory with:");
let hasRequiredVars = true;
envVarsToCopy.forEach((varName) => {
const hasValue = !!process.env[varName];
console.log(` - ${varName}: ${hasValue ? "✓" : "✗ (missing)"}`);
// Check for required variables
if (varName === "ANTHROPIC_API_KEY" && !hasValue) {
hasRequiredVars = false;
}
});
if (!hasRequiredVars) {
console.warn("\n⚠️ Warning: ANTHROPIC_API_KEY is not set or empty.");
console.warn(
" Please ensure you have a valid API key in your .env file.",
);
console.warn(
" Get your API key from: https://console.anthropic.com/api-keys\n",
);
}
// Change to the temp directory // Change to the temp directory
process.chdir(tempDir); process.chdir(tempDir);
@@ -145,8 +94,35 @@ async function runPlay(
console.log(prompt); console.log(prompt);
console.log("─".repeat(50)); console.log("─".repeat(50));
// Run main with the params object // Set environment variables from our .env for the action to use
const result = await main({ prompt }); const { EXPECTED_INPUTS } = await import("./main");
EXPECTED_INPUTS.forEach((inputName) => {
const value = process.env[inputName];
if (value) {
process.env[`INPUT_${inputName.toLowerCase()}`] = value;
}
});
// Run main with the new params structure
const inputs: any = {
prompt,
anthropic_api_key: process.env.ANTHROPIC_API_KEY || "",
};
// Add optional properties only if they exist
if (process.env.GITHUB_TOKEN) {
inputs.github_token = process.env.GITHUB_TOKEN;
}
if (process.env.GITHUB_INSTALLATION_TOKEN) {
inputs.github_installation_token = process.env.GITHUB_INSTALLATION_TOKEN;
}
const result = await main({
inputs,
env: process.env as Record<string, string>,
cwd: process.cwd(),
});
if (result.success) { if (result.success) {
console.log("✅ Test completed successfully"); console.log("✅ Test completed successfully");
@@ -171,15 +147,8 @@ program
.name("play") .name("play")
.description("Test the Pullfrog action with various prompts") .description("Test the Pullfrog action with various prompts")
.version("1.0.0") .version("1.0.0")
.argument( .argument("[file]", "Prompt file to use (.txt, .json, or .ts)", "fixtures/basic.txt")
"[file]", .option("--act", "Use Docker/act to run the action instead of running directly")
"Prompt file to use (.txt, .json, or .ts)",
"fixtures/basic.txt",
)
.option(
"--act",
"Use Docker/act to run the action instead of running directly",
)
.action(async (file: string, options: { act?: boolean }) => { .action(async (file: string, options: { act?: boolean }) => {
await runPlay(file, options); await runPlay(file, options);
}); });
+22 -41
View File
@@ -1,59 +1,36 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { config, parse } from "dotenv"; import { config } from "dotenv";
import { buildAction, setupTestRepo } from "./setup";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
// Environment variables that should be passed as secrets to the workflow
const ENV_VARS = ["ANTHROPIC_API_KEY", "GITHUB_INSTALLATION_TOKEN"];
export function runAct(prompt: string): void { export function runAct(prompt: string): void {
// First, ensure the scratch repo is cloned
const tempDir = join(__dirname, "..", ".temp"); const tempDir = join(__dirname, "..", ".temp");
const actionPath = join(__dirname, "..");
// Check if .temp exists and either reset it or clone it
if (existsSync(tempDir)) {
console.log("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
} else {
console.log("📦 Cloning pullfrogai/scratch into .temp...");
const repoUrl = "git@github.com:pullfrogai/scratch.git";
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
}
const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
const envPath = join(__dirname, "..", "..", ".env"); const envPath = join(__dirname, "..", "..", ".env");
// Load environment variables into process // Setup test repository
setupTestRepo({ tempDir });
// Load environment variables
config({ path: envPath }); config({ path: envPath });
// Parse environment variables from .env file to get keys // Build action bundles
let envVars: string[] = []; buildAction(actionPath);
try {
const content = readFileSync(envPath, "utf8");
const parsed = parse(content);
envVars = Object.keys(parsed);
} catch (error) {
console.warn(
`Warning: Could not read .env file: ${(error as Error).message}`,
);
}
// Build fresh bundles with esbuild const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml");
const actionPath = join(__dirname, "..");
console.log("🔨 Building fresh bundles with esbuild...");
execSync("node esbuild.config.js", {
cwd: actionPath,
stdio: "inherit",
});
// Create minimal dist for act (avoids pnpm symlink issues) // Create minimal dist for act (avoids pnpm symlink issues)
const distPath = join(actionPath, ".act-dist"); const distPath = join(actionPath, ".act-dist");
console.log("📦 Creating minimal distribution for act..."); console.log("📦 Creating minimal distribution for act...");
execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: true }); execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: "/bin/bash" });
// Copy only necessary files (bundled, no node_modules needed) // Copy only necessary files (bundled, no node_modules needed)
["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => { ["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => {
@@ -79,11 +56,15 @@ export function runAct(prompt: string): void {
`pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks `pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks
]; ];
// Add all environment variables as secrets (without values) // Add environment variables as secrets that will be available to the workflow
envVars.forEach((key) => { ENV_VARS.forEach((key) => {
actCommandParts.push("-s", key); if (process.env[key]) {
actCommandParts.push("-s", key);
}
}); });
// We only need the specific ENV_VARS, no need to add other variables
const actCommand = actCommandParts.join(" "); const actCommand = actCommandParts.join(" ");
console.log("🚀 Running act with prompt:"); console.log("🚀 Running act with prompt:");
+56
View File
@@ -0,0 +1,56 @@
import * as core from "@actions/core";
/**
* Setup GitHub installation token for the action
*/
export async function setupGitHubInstallationToken(): Promise<string> {
// Check if we have an installation token from inputs or environment
const inputToken = core.getInput("github_installation_token");
const envToken = process.env.GITHUB_INSTALLATION_TOKEN;
const existingToken = inputToken || envToken;
if (existingToken) {
core.info("Using provided GitHub installation token");
return existingToken;
}
core.info("No cached installation token found, generating OIDC token...");
try {
// Generate OIDC token for our API
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
// Exchange OIDC token for installation token
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
}
const tokenData = await tokenResponse.json();
core.info(
`Installation token obtained for ${tokenData.owner}/${tokenData.repository || "all repositories"}`
);
// Set the token as an environment variable for this run
process.env.GITHUB_INSTALLATION_TOKEN = tokenData.token;
return tokenData.token;
} catch (error) {
throw new Error(
`Failed to setup GitHub installation token: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
+2
View File
@@ -1,3 +1,5 @@
export * from "./files"; export * from "./files";
export * from "./github";
export * from "./setup";
export * from "./subprocess"; export * from "./subprocess";
export * from "./table"; export * from "./table";
+51
View File
@@ -0,0 +1,51 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
export interface SetupOptions {
tempDir: string;
repoUrl?: string;
forceClean?: boolean;
}
/**
* Setup the test repository for running actions
*/
export function setupTestRepo(options: SetupOptions): void {
const {
tempDir,
repoUrl = "git@github.com:pullfrogai/scratch.git",
forceClean = false,
} = options;
// Handle existing temp directory
if (existsSync(tempDir)) {
if (forceClean) {
console.log("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
// Clone the repository
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
} else {
console.log("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
}
} else {
console.log("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
}
}
/**
* Build the action bundles
*/
export function buildAction(actionPath: string): void {
console.log("🔨 Building fresh bundles with esbuild...");
execSync("node esbuild.config.js", {
cwd: actionPath,
stdio: "inherit",
});
}