diff --git a/entry.cjs b/entry.cjs index bcc86f5..0085762 100755 --- a/entry.cjs +++ b/entry.cjs @@ -25984,45 +25984,167 @@ async function main(params) { } // utils/github.ts +var import_node_crypto = require("node:crypto"); var core3 = __toESM(require_core(), 1); -async function setupGitHubInstallationToken() { + +// utils/repo-context.ts +function resolveRepoContext() { + const githubRepo = process.env.GITHUB_REPOSITORY; + if (!githubRepo) { + throw new Error("GITHUB_REPOSITORY environment variable is required"); + } + const [owner, name] = githubRepo.split("/"); + if (!owner || !name) { + throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`); + } + return { owner, name }; +} + +// utils/github.ts +function checkExistingToken() { const inputToken = core3.getInput("github_installation_token"); const envToken = process.env.GITHUB_INSTALLATION_TOKEN; - const existingToken = inputToken || envToken; + return inputToken || envToken || null; +} +function isGitHubActionsEnvironment() { + return Boolean(process.env.GITHUB_ACTIONS); +} +async function acquireTokenViaOIDC() { + core3.info("Generating OIDC token..."); + const oidcToken = await core3.getIDToken("pullfrog-api"); + core3.info("OIDC token generated successfully"); + const apiUrl = process.env.API_URL || "https://pullfrog.ai"; + core3.info("Exchanging OIDC token for installation token..."); + 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(); + core3.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); + return tokenData.token; +} +var base64UrlEncode = (str) => { + return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +}; +var generateJWT = (appId, privateKey) => { + const now = Math.floor(Date.now() / 1e3); + const payload = { + iat: now - 60, + exp: now + 5 * 60, + iss: appId + }; + const header = { + alg: "RS256", + typ: "JWT" + }; + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signaturePart = `${encodedHeader}.${encodedPayload}`; + const signature = (0, import_node_crypto.createSign)("RSA-SHA256").update(signaturePart).sign(privateKey, "base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + return `${signaturePart}.${signature}`; +}; +var githubRequest = async (path, options = {}) => { + const { method = "GET", headers = {}, body } = options; + const url = `https://api.github.com${path}`; + const requestHeaders = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", + ...headers + }; + const response = await fetch(url, { + method, + headers: requestHeaders, + ...body && { body } + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText} +${errorText}` + ); + } + return response.json(); +}; +var checkRepositoryAccess = async (token, repoOwner, repoName) => { + try { + const response = await githubRequest("/installation/repositories", { + headers: { Authorization: `token ${token}` } + }); + return response.repositories.some( + (repo) => repo.owner.login === repoOwner && repo.name === repoName + ); + } catch { + return false; + } +}; +var createInstallationToken = async (jwt, installationId) => { + const response = await githubRequest( + `/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` } + } + ); + return response.token; +}; +var findInstallationId = async (jwt, repoOwner, repoName) => { + const installations = await githubRequest("/app/installations", { + headers: { Authorization: `Bearer ${jwt}` } + }); + for (const installation of installations) { + try { + const tempToken = await createInstallationToken(jwt, installation.id); + const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName); + if (hasAccess) { + return installation.id; + } + } catch { + } + } + throw new Error( + `No installation found with access to ${repoOwner}/${repoName}. Ensure the GitHub App is installed on the target repository.` + ); +}; +async function acquireTokenViaGitHubApp() { + const repoContext = resolveRepoContext(); + const config = { + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"), + repoOwner: repoContext.owner, + repoName: repoContext.name + }; + const jwt = generateJWT(config.appId, config.privateKey); + const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName); + const token = await createInstallationToken(jwt, installationId); + return token; +} +async function acquireNewToken() { + if (isGitHubActionsEnvironment()) { + return await acquireTokenViaOIDC(); + } else { + return await acquireTokenViaGitHubApp(); + } +} +async function setupGitHubInstallationToken() { + const existingToken = checkExistingToken(); if (existingToken) { core3.setSecret(existingToken); core3.info("Using provided GitHub installation token"); return existingToken; } - core3.info("Generating OIDC token..."); - try { - const oidcToken = await core3.getIDToken("pullfrog-api"); - core3.info("OIDC token generated successfully"); - const apiUrl = process.env.API_URL || "https://pullfrog.ai"; - core3.info("Exchanging OIDC token for installation token..."); - 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(); - core3.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); - core3.setSecret(tokenData.token); - process.env.GITHUB_INSTALLATION_TOKEN = tokenData.token; - return tokenData.token; - } catch (error2) { - throw new Error( - `Failed to setup GitHub installation token: ${error2 instanceof Error ? error2.message : "Unknown error"}` - ); - } + const token = await acquireNewToken(); + core3.setSecret(token); + process.env.GITHUB_INSTALLATION_TOKEN = token; + return token; } // entry.ts diff --git a/package.json b/package.json index 7075ced..1c20a97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/action", - "version": "0.0.14", + "version": "0.0.15", "type": "module", "files": [ "index.js", diff --git a/play.ts b/play.ts index e519937..5c5cb4e 100644 --- a/play.ts +++ b/play.ts @@ -5,7 +5,7 @@ import arg from "arg"; import { config } from "dotenv"; import { main } from "./main.ts"; import { runAct } from "./utils/act.ts"; -import { generateInstallationToken } from "./utils/generate-installation-token.ts"; +import { setupGitHubInstallationToken } from "./utils/github.ts"; import { setupTestRepo } from "./utils/setup.ts"; config(); @@ -53,10 +53,10 @@ export async function run( inputs.github_token = process.env.GITHUB_TOKEN; } - console.log("🔑 Generating GitHub installation token..."); - const installationToken = await generateInstallationToken(); + console.log("🔑 Setting up GitHub installation token..."); + const installationToken = await setupGitHubInstallationToken(); inputs.github_installation_token = installationToken; - console.log("✅ GitHub installation token generated successfully"); + console.log("✅ GitHub installation token setup successfully"); const envWithToken = { ...process.env, diff --git a/utils/generate-installation-token.ts b/utils/generate-installation-token.ts deleted file mode 100644 index 99aec60..0000000 --- a/utils/generate-installation-token.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { createSign } from "node:crypto"; -import { config } from "dotenv"; -import { resolveRepoContext } from "./repo-context.ts"; - -config(); - -interface GitHubAppConfig { - appId: string; - privateKey: string; - repoOwner: string; - repoName: string; -} - -interface Installation { - id: number; - account: { - login: string; - type: string; - }; -} - -interface Repository { - owner: { - login: string; - }; - name: string; -} - -interface InstallationTokenResponse { - token: string; - expires_at: string; -} - -interface RepositoriesResponse { - repositories: Repository[]; -} - -const base64UrlEncode = (str: string): string => { - return Buffer.from(str) - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -}; - -const generateJWT = (appId: string, privateKey: string): string => { - const now = Math.floor(Date.now() / 1000); - const payload = { - iat: now - 60, - exp: now + 5 * 60, - iss: appId, - }; - - const header = { - alg: "RS256", - typ: "JWT", - }; - - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode(JSON.stringify(payload)); - const signaturePart = `${encodedHeader}.${encodedPayload}`; - - const signature = createSign("RSA-SHA256") - .update(signaturePart) - .sign(privateKey, "base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); - - return `${signaturePart}.${signature}`; -}; - -const githubRequest = async ( - path: string, - options: { - method?: string; - headers?: Record; - body?: string; - } = {} -): Promise => { - const { method = "GET", headers = {}, body } = options; - - const url = `https://api.github.com${path}`; - const requestHeaders = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", - ...headers, - }; - - const response = await fetch(url, { - method, - headers: requestHeaders, - ...(body && { body }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}` - ); - } - - return response.json() as T; -}; - -const checkRepositoryAccess = async ( - token: string, - repoOwner: string, - repoName: string -): Promise => { - try { - const response = await githubRequest("/installation/repositories", { - headers: { Authorization: `token ${token}` }, - }); - - return response.repositories.some( - (repo) => repo.owner.login === repoOwner && repo.name === repoName - ); - } catch { - return false; - } -}; - -const createInstallationToken = async (jwt: string, installationId: number): Promise => { - const response = await githubRequest( - `/app/installations/${installationId}/access_tokens`, - { - method: "POST", - headers: { Authorization: `Bearer ${jwt}` }, - } - ); - - return response.token; -}; - -const findInstallationId = async ( - jwt: string, - repoOwner: string, - repoName: string -): Promise => { - const installations = await githubRequest("/app/installations", { - headers: { Authorization: `Bearer ${jwt}` }, - }); - - for (const installation of installations) { - try { - const tempToken = await createInstallationToken(jwt, installation.id); - const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName); - - if (hasAccess) { - return installation.id; - } - } catch {} - } - - throw new Error( - `No installation found with access to ${repoOwner}/${repoName}. ` + - "Ensure the GitHub App is installed on the target repository." - ); -}; - -export const generateInstallationToken = async (): Promise => { - const repoContext = resolveRepoContext(); - - const config: GitHubAppConfig = { - appId: process.env.GITHUB_APP_ID!, - privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!, - repoOwner: repoContext.owner, - repoName: repoContext.name, - }; - - const jwt = generateJWT(config.appId, config.privateKey); - const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName); - const token = await createInstallationToken(jwt, installationId); - - return token; -}; diff --git a/utils/github.ts b/utils/github.ts index 59ea9a6..add25a6 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -1,4 +1,6 @@ +import { createSign } from "node:crypto"; import * as core from "@actions/core"; +import { resolveRepoContext } from "./repo-context.ts"; export interface InstallationToken { token: string; @@ -10,55 +12,241 @@ export interface InstallationToken { owner?: string; } +interface GitHubAppConfig { + appId: string; + privateKey: string; + repoOwner: string; + repoName: string; +} + +interface Installation { + id: number; + account: { + login: string; + type: string; + }; +} + +interface Repository { + owner: { + login: string; + }; + name: string; +} + +interface InstallationTokenResponse { + token: string; + expires_at: string; +} + +interface RepositoriesResponse { + repositories: Repository[]; +} + +function checkExistingToken(): string | null { + const inputToken = core.getInput("github_installation_token"); + const envToken = process.env.GITHUB_INSTALLATION_TOKEN; + return inputToken || envToken || null; +} + +function isGitHubActionsEnvironment(): boolean { + return Boolean(process.env.GITHUB_ACTIONS); +} + +async function acquireTokenViaOIDC(): Promise { + core.info("Generating OIDC token..."); + + const oidcToken = await core.getIDToken("pullfrog-api"); + core.info("OIDC token generated successfully"); + + const apiUrl = process.env.API_URL || "https://pullfrog.ai"; + + core.info("Exchanging OIDC token for installation token..."); + 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()) as InstallationToken; + core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); + + return tokenData.token; +} + +const base64UrlEncode = (str: string): string => { + return Buffer.from(str) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; + +const generateJWT = (appId: string, privateKey: string): string => { + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, + exp: now + 5 * 60, + iss: appId, + }; + + const header = { + alg: "RS256", + typ: "JWT", + }; + + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signaturePart = `${encodedHeader}.${encodedPayload}`; + + const signature = createSign("RSA-SHA256") + .update(signaturePart) + .sign(privateKey, "base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + return `${signaturePart}.${signature}`; +}; + +const githubRequest = async ( + path: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {} +): Promise => { + const { method = "GET", headers = {}, body } = options; + + const url = `https://api.github.com${path}`; + const requestHeaders = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", + ...headers, + }; + + const response = await fetch(url, { + method, + headers: requestHeaders, + ...(body && { body }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText}\n${errorText}` + ); + } + + return response.json() as T; +}; + +const checkRepositoryAccess = async ( + token: string, + repoOwner: string, + repoName: string +): Promise => { + try { + const response = await githubRequest("/installation/repositories", { + headers: { Authorization: `token ${token}` }, + }); + + return response.repositories.some( + (repo) => repo.owner.login === repoOwner && repo.name === repoName + ); + } catch { + return false; + } +}; + +const createInstallationToken = async (jwt: string, installationId: number): Promise => { + const response = await githubRequest( + `/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` }, + } + ); + + return response.token; +}; + +const findInstallationId = async ( + jwt: string, + repoOwner: string, + repoName: string +): Promise => { + const installations = await githubRequest("/app/installations", { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + for (const installation of installations) { + try { + const tempToken = await createInstallationToken(jwt, installation.id); + const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName); + + if (hasAccess) { + return installation.id; + } + } catch {} + } + + throw new Error( + `No installation found with access to ${repoOwner}/${repoName}. ` + + "Ensure the GitHub App is installed on the target repository." + ); +}; + +async function acquireTokenViaGitHubApp(): Promise { + const repoContext = resolveRepoContext(); + + const config: GitHubAppConfig = { + appId: process.env.GITHUB_APP_ID!, + privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n")!, + repoOwner: repoContext.owner, + repoName: repoContext.name, + }; + + const jwt = generateJWT(config.appId, config.privateKey); + const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName); + const token = await createInstallationToken(jwt, installationId); + + return token; +} + +async function acquireNewToken(): Promise { + if (isGitHubActionsEnvironment()) { + return await acquireTokenViaOIDC(); + } else { + return await acquireTokenViaGitHubApp(); + } +} + /** * Setup GitHub installation token for the action */ export async function setupGitHubInstallationToken(): Promise { - const inputToken = core.getInput("github_installation_token"); - const envToken = process.env.GITHUB_INSTALLATION_TOKEN; - - const existingToken = inputToken || envToken; + const existingToken = checkExistingToken(); if (existingToken) { core.setSecret(existingToken); core.info("Using provided GitHub installation token"); return existingToken; } - core.info("Generating OIDC token..."); - - try { - const oidcToken = await core.getIDToken("pullfrog-api"); - core.info("OIDC token generated successfully"); - - const apiUrl = process.env.API_URL || "https://pullfrog.ai"; - - core.info("Exchanging OIDC token for installation token..."); - 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()) as InstallationToken; - core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); - - core.setSecret(tokenData.token); - - 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"}` - ); - } + const token = await acquireNewToken(); + + core.setSecret(token); + process.env.GITHUB_INSTALLATION_TOKEN = token; + + return token; }