60cc8772a6
* fix(log-audit): kill 404 noise from `/api/github/installation-token` at source (#693) Closes #693. Issue diagnosed a surface symptom (`log.error` on expected 404s) but missed the actual root causes. Investigation revealed two distinct populations producing identical 3-call 404 bursts: 1. **Fork-CI on `pullfrog/pullfrog`**: `test-token.yml` and `trigger-sync.yml` ship with `on: push: main`, so every fork inherits them and 404s our token endpoint on first push. Self-inflicted noise that scales with fork count. 2. **Real users hitting the full action without installing the App**: `/api/repo/.../run-context` uses the caller's `GITHUB_TOKEN` to read the repo from GitHub and then unconditionally lazy-provisions Account+Repo rows via `fetchOrCreateRepo`, even when the App isn't installed. Generates phantom DB rows and false `new account created` team@ alerts. (Confirmed via Prisma: `ezcorp-org` has an Account row with `installerLogin: null`, never installed our App.) Both populations then trip the client retry loop in `acquireTokenViaOIDC`, which matched `"Token exchange failed"` and retried 3× on terminal 4xx — tripling log volume and wasting CI time. ## Changes - `action/.github/workflows/{test-token,trigger-sync}.yml`: gate jobs with `if: github.repository == 'pullfrog/pullfrog'`. Forks inherit the files but the jobs no-op. - `app/api/repo/[owner]/[repo]/run-context/route.ts`: call `getRepoInstallation` first; return 404 with install URL if the App isn't installed, before any DB writes or GitHub repo fetch. - `action/utils/github.ts`: introduce `TokenExchangeError` for non-2xx server responses; `acquireNewToken` no longer retries it. Retry now fires only on genuine network/timeout failures. 404 surfaces a user-actionable error pointing at the install URL. - `app/api/github/installation-token/route.ts`: move `log.error` inside the 500 branch only. 404 branch is silent (expected user-state) and returns the same install URL message for consistency. ## Effect - Better Stack `level=error` lines from this path: 6/day → 0. - Failed user-trial CI time: 3 wasted token requests → 1. - User-facing error: opaque `Token exchange failed: 404` → actionable install URL. - No more phantom Account rows from never-installed callers. Skipped per design discussion: phantom-account cleanup (conservative — stop the bleed, leave history), `AGENTS.md` rule (overgeneralized). * review: address oracle leak + per-env install URL + retryable 5xx Addresses pullfrog[bot] (IMPORTANT) and Copilot review findings on #708: - **Install-status oracle in `run-context`** [pullfrog, Copilot]: `getRepoInstallation` runs with our App's JWT, *before* the caller's bearer token is validated against the repo. Pre-PR the route was uniformly bad-token-shaped; the new install-specific 404 turned it into an unauthenticated oracle distinguishing "Pullfrog installed here" from "not installed". Collapsed the 404 message to match the outer catch's ambiguous "repository not found or token lacks access". Legit runners still get the actionable install URL from `/api/github/installation-token`, which IS gated by OIDC. - **Hardcoded `github.com/apps/pullfrog`** [Copilot]: server-side `installation-token` now uses `GITHUB_APP_INSTALL_URL` from `app/globals.ts`, so dev/staging deployments with a different `GITHUB_APP_SLUG` direct users to the correct app. Action-side echoes the server's `error` body when present (single source of truth) and falls back to a generic message only if the body isn't JSON. - **Transient 5xx/429 made terminal** [Copilot]: `shouldRetry` now returns `true` for `TokenExchangeError` with `status >= 500` or `status === 429`. 4xx remains terminal (the actual #693 fix). Real outages no longer fail the workflow immediately. - **Stale comment** [pullfrog, Copilot]: reworded the comment at `installation-token/route.ts:141` to reflect the new retry policy ("the action surfaces this once (no retry)" instead of "the action retries on this"). * review: restore caller-token-first auth in run-context Pre-PR, `getEnrichedRepo({owner, repo, token})` used the caller's token as the auth boundary — `getRepo({token})` succeeding was the proof-of-access check. My initial install-gate inverted the order and ran the App-credentialed `getRepoInstallation` first, which is how it became: - an install-status oracle (pullfrog bot, addressed previously by matching the outer-catch wording), and - an outbound amplifier against our App JWT for arbitrary `owner/repo` (pullfrog bot, this commit). Reordered so `getRepo({token})` runs first. Garbage / unauthorized bearers get rejected by github (mapped to 403 by the outer catch) before any App-credentialed call fires. `getRepo` is cached 5min, so `getEnrichedRepo` below remains a free re-hit.
514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
import { createSign } from "node:crypto";
|
|
import { rename, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import * as core from "@actions/core";
|
|
import { throttling } from "@octokit/plugin-throttling";
|
|
import { Octokit } from "@octokit/rest";
|
|
import { apiFetch } from "./apiFetch.ts";
|
|
import { retry } from "./retry.ts";
|
|
|
|
function isObject(value: unknown) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
// we don't get access to the actual class from @octokit/rest
|
|
// it's reachable from @octokit/request-error but we'd have to add a dependency on it
|
|
// and it would pose a risk of accidentally pulling a different version of that class (node_modules dep graphs ❤️)
|
|
// so it's safer to ducktype this
|
|
interface OctokitResponseShim {
|
|
headers: Record<string, string | number | undefined>;
|
|
}
|
|
|
|
export interface InstallationToken {
|
|
token: string;
|
|
expires_at: string;
|
|
installation_id: number;
|
|
repository: string;
|
|
ref: string;
|
|
runner_environment: string;
|
|
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 isOIDCAvailable(): boolean {
|
|
// OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
|
|
return Boolean(
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
);
|
|
}
|
|
|
|
type ReadWrite = "read" | "write";
|
|
type WriteOnly = "write";
|
|
|
|
/**
|
|
* GitHub App installation access token permissions.
|
|
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
|
|
* fields and allowed values come from the `app-permissions` OpenAPI schema.
|
|
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
|
|
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
|
|
*/
|
|
type GitHubAppPermissions = {
|
|
actions?: ReadWrite;
|
|
artifact_metadata?: ReadWrite;
|
|
attestations?: ReadWrite;
|
|
checks?: ReadWrite;
|
|
contents?: ReadWrite;
|
|
deployments?: ReadWrite;
|
|
discussions?: ReadWrite;
|
|
issues?: ReadWrite;
|
|
packages?: ReadWrite;
|
|
pages?: ReadWrite;
|
|
pull_requests?: ReadWrite;
|
|
security_events?: ReadWrite;
|
|
statuses?: ReadWrite;
|
|
workflows?: WriteOnly;
|
|
};
|
|
|
|
type AcquireTokenOptions = {
|
|
repos?: string[];
|
|
permissions?: GitHubAppPermissions;
|
|
};
|
|
|
|
/**
|
|
* Thrown when our token-exchange endpoint returns a non-2xx response.
|
|
* The retry policy in `acquireNewToken` looks for this concrete type to
|
|
* skip retries — 4xx is terminal user state (not-installed, not-authorized)
|
|
* and 5xx is rare enough that re-running the workflow is the right escape
|
|
* hatch. Genuine network failures throw plain `Error` and stay retryable.
|
|
*/
|
|
class TokenExchangeError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "TokenExchangeError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
|
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
|
|
|
const repos = [...(opts?.repos ?? [])];
|
|
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
|
if (targetRepo) {
|
|
repos.push(targetRepo);
|
|
}
|
|
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
|
|
|
const timeoutMs = 30000;
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const tokenResponse = await apiFetch({
|
|
path: `/api/github/installation-token${reposParam}`,
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${oidcToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!tokenResponse.ok) {
|
|
// prefer the server-side `error` field — it's the single source of
|
|
// truth for the install URL (uses GITHUB_APP_INSTALL_URL, which
|
|
// varies per env / GITHUB_APP_SLUG). fall back to a generic message
|
|
// if the body isn't JSON or doesn't carry an `error` field.
|
|
let serverMessage: string | undefined;
|
|
try {
|
|
const body = (await tokenResponse.json()) as { error?: unknown };
|
|
if (typeof body.error === "string") serverMessage = body.error;
|
|
} catch {
|
|
// body wasn't JSON — fall through to the generic message
|
|
}
|
|
throw new TokenExchangeError(
|
|
tokenResponse.status,
|
|
serverMessage ??
|
|
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`
|
|
);
|
|
}
|
|
|
|
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
|
return tokenData.token;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new Error(`Token exchange timed out after ${timeoutMs}ms`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 <T>(
|
|
path: string,
|
|
options: {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
} = {}
|
|
): Promise<T> => {
|
|
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<boolean> => {
|
|
try {
|
|
const response = await githubRequest<RepositoriesResponse>("/installation/repositories", {
|
|
headers: { Authorization: `token ${token}` },
|
|
});
|
|
|
|
const ownerLower = repoOwner.toLowerCase();
|
|
const nameLower = repoName.toLowerCase();
|
|
return response.repositories.some(
|
|
(repo) =>
|
|
repo.owner.login.toLowerCase() === ownerLower && repo.name.toLowerCase() === nameLower
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const createInstallationToken = async (
|
|
jwt: string,
|
|
installationId: number,
|
|
permissions?: GitHubAppPermissions
|
|
): Promise<string> => {
|
|
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
};
|
|
if (permissions) {
|
|
requestOpts.body = JSON.stringify({ permissions });
|
|
}
|
|
const response = await githubRequest<InstallationTokenResponse>(
|
|
`/app/installations/${installationId}/access_tokens`,
|
|
requestOpts
|
|
);
|
|
|
|
return response.token;
|
|
};
|
|
|
|
const findInstallationId = async (
|
|
jwt: string,
|
|
repoOwner: string,
|
|
repoName: string
|
|
): Promise<number> => {
|
|
const installations = await githubRequest<Installation[]>("/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."
|
|
);
|
|
};
|
|
|
|
// for local development only
|
|
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
|
|
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_PRIVATE_KEY) {
|
|
throw new Error(
|
|
"cannot acquire token via GitHub App: GITHUB_APP_ID and GITHUB_PRIVATE_KEY must be set"
|
|
);
|
|
}
|
|
|
|
const repoContext = parseRepoContext();
|
|
|
|
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);
|
|
return await createInstallationToken(jwt, installationId, opts?.permissions);
|
|
}
|
|
|
|
/**
|
|
* ensure a GitHub token is available in the environment.
|
|
*
|
|
* when OIDC is available (CI), always mints a fresh token scoped to
|
|
* GITHUB_REPOSITORY — overriding any inherited GITHUB_TOKEN that may
|
|
* be scoped to the wrong repo.
|
|
*
|
|
* otherwise falls back to GitHub App credentials for local development.
|
|
*
|
|
* only called from play.ts (test/dev path) — the live action calls
|
|
* main() directly and never calls this.
|
|
*/
|
|
export async function ensureGitHubToken(): Promise<void> {
|
|
// when OIDC is available, always mint a fresh token scoped to
|
|
// GITHUB_REPOSITORY. the inherited GITHUB_TOKEN may be scoped to a
|
|
// different repo (e.g., runner token for pullfrog/app when tests
|
|
// target pullfrog/test-repo).
|
|
if (isOIDCAvailable()) {
|
|
const token = await acquireNewToken();
|
|
process.env.GITHUB_TOKEN = token;
|
|
return;
|
|
}
|
|
|
|
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
|
const token = await acquireNewToken();
|
|
process.env.GITHUB_TOKEN = token;
|
|
}
|
|
}
|
|
|
|
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
|
|
if (isOIDCAvailable()) {
|
|
return await retry(() => acquireTokenViaOIDC(opts), {
|
|
label: "token exchange",
|
|
shouldRetry: (error) => {
|
|
// 4xx is terminal user state (app not installed, permissions wrong) —
|
|
// retrying just triples our log noise and the user's CI bill (see
|
|
// #693). 5xx/429 are transient (vercel cold start, github outage,
|
|
// rate limit) and should ride the existing backoff.
|
|
if (error instanceof TokenExchangeError) return error.status >= 500 || error.status === 429;
|
|
return (
|
|
error instanceof Error &&
|
|
(error.message.includes("timed out") ||
|
|
error.message.includes("fetch failed") ||
|
|
error.message.includes("ECONNRESET") ||
|
|
error.message.includes("ETIMEDOUT"))
|
|
);
|
|
},
|
|
});
|
|
} else {
|
|
// local development via GitHub App
|
|
return await acquireTokenViaGitHubApp(opts);
|
|
}
|
|
}
|
|
|
|
export interface RepoContext {
|
|
owner: string;
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Parse repository context from GITHUB_REPOSITORY environment variable.
|
|
*/
|
|
export function parseRepoContext(): RepoContext {
|
|
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 };
|
|
}
|
|
|
|
export type OctokitWithPlugins = InstanceType<
|
|
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
|
|
>;
|
|
|
|
export interface ResourceUsage {
|
|
requestCount: number;
|
|
rateLimitRemaining: number | null;
|
|
rateLimitResetMs: number | null;
|
|
}
|
|
|
|
function emptyResourceUsage(): ResourceUsage {
|
|
return {
|
|
requestCount: 0,
|
|
rateLimitRemaining: null,
|
|
rateLimitResetMs: null,
|
|
};
|
|
}
|
|
|
|
const usageByResource: Record<string, ResourceUsage> = {
|
|
core: emptyResourceUsage(),
|
|
graphql: emptyResourceUsage(),
|
|
};
|
|
|
|
export interface UsageSummary {
|
|
version: 1;
|
|
github: {
|
|
core: ResourceUsage;
|
|
graphql: ResourceUsage;
|
|
};
|
|
}
|
|
|
|
function getGitHubUsageSummary(): UsageSummary {
|
|
return {
|
|
version: 1,
|
|
github: {
|
|
core: usageByResource.core,
|
|
graphql: usageByResource.graphql,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function writeGitHubUsageSummaryToFile(path: string): Promise<void> {
|
|
const summary = getGitHubUsageSummary();
|
|
const tmpPath = join(dirname(path), `.usage-summary-${process.pid}.tmp`);
|
|
await writeFile(tmpPath, JSON.stringify(summary));
|
|
await rename(tmpPath, path);
|
|
}
|
|
|
|
export function createOctokit(token: string): OctokitWithPlugins {
|
|
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
|
|
// we can't use it directly because it's stuck on `@octokit/core@v5` and we use the hottest `@octokit/core@v7`
|
|
const OctokitWithPlugins = Octokit.plugin(throttling);
|
|
const octokit = new OctokitWithPlugins({
|
|
auth: token,
|
|
throttle: {
|
|
onRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
|
|
return retryCount <= 2;
|
|
},
|
|
onSecondaryRateLimit: (_retryAfter, _options, _octokit, retryCount) => {
|
|
return retryCount <= 2;
|
|
},
|
|
},
|
|
});
|
|
|
|
const onResponse = (response: OctokitResponseShim) => {
|
|
const resource = response.headers["x-ratelimit-resource"];
|
|
if (!resource) {
|
|
return response;
|
|
}
|
|
usageByResource[resource] ??= emptyResourceUsage();
|
|
const usage = usageByResource[resource];
|
|
usage.requestCount++;
|
|
const remaining = response.headers["x-ratelimit-remaining"];
|
|
const reset = response.headers["x-ratelimit-reset"];
|
|
if (remaining !== undefined) {
|
|
usage.rateLimitRemaining = Number(remaining);
|
|
}
|
|
if (reset !== undefined) {
|
|
usage.rateLimitResetMs = Number(reset) * 1000;
|
|
}
|
|
return response;
|
|
};
|
|
|
|
octokit.hook.wrap("request", async (request, options) => {
|
|
try {
|
|
const response = await request(options);
|
|
onResponse(response);
|
|
return response;
|
|
} catch (error) {
|
|
if (
|
|
isObject(error) &&
|
|
"response" in error &&
|
|
isObject(error.response) &&
|
|
"headers" in error.response &&
|
|
isObject(error.response.headers)
|
|
) {
|
|
onResponse(error.response as OctokitResponseShim);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
return octokit;
|
|
}
|