Upload files to "src/mutator/action"
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user