cache-apt-pkgs-action/scripts/ops/pr_checks.mts
2026-07-25 16:08:50 -07:00

500 lines
12 KiB
TypeScript
Executable file

#!/usr/bin/env -S node --experimental-strip-types
import process from "node:process";
import path from "node:path";
import { defineCommand, defineOptions } from "@robingenz/zli";
import { z } from "zod";
import {
compareSemVer,
formatSemVer,
isAdminActor,
loadActionRepoData,
parseActionRepoSlug,
resolveActionRefSha,
SHA_PIN_PATTERN,
} from "./opslib.mts";
import {
createCliConfig,
fail,
logError,
logInfo,
logWarn,
logSuccess,
ROOT_DIR,
runCli,
runMain,
runCaptureOutput,
tryRun,
} from "../devopslib.mts";
type PrCheckOptions = {
baseRef: string;
headRef: string;
allowAdminBypass: boolean;
};
type ActionUse = {
actionPath: string;
ref: string;
lineNumber: number;
};
type ActionRefChange = {
filePath: string;
lineNumber: number;
actionPath: string;
previousRef: string | null;
currentRef: string;
};
function runGitNameOnly(args: string[], cwd: string): string[] {
const output = runCaptureOutput("git", args, {
cwd,
});
if (!output) {
return [];
}
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function tryGit(args: string[]): {
ok: boolean;
stdout: string;
stderr: string;
} {
const result = tryRun("git", args, {
cwd: ROOT_DIR,
stdio: "pipe",
});
return {
ok: result.ok,
stdout: String(result.stdout ?? "").trim(),
stderr: String(result.stderr ?? "").trim(),
};
}
function extractActionUses(content: string): ActionUse[] {
const usesPattern = /^\s*uses:\s*["']?([^"'\s#]+)["']?/;
const uses: ActionUse[] = [];
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const match = usesPattern.exec(line);
if (!match) {
continue;
}
const spec = match[1] ?? "";
if (
!spec.includes("@") ||
spec.startsWith("./") ||
spec.startsWith("docker://")
) {
continue;
}
const atIndex = spec.lastIndexOf("@");
if (atIndex <= 0 || atIndex === spec.length - 1) {
continue;
}
const actionPath = spec.slice(0, atIndex);
const ref = spec.slice(atIndex + 1);
uses.push({
actionPath,
ref,
lineNumber: index + 1,
});
}
return uses;
}
function groupUsesByActionPath(uses: ActionUse[]): Map<string, ActionUse[]> {
const grouped = new Map<string, ActionUse[]>();
for (const use of uses) {
const list = grouped.get(use.actionPath) ?? [];
list.push(use);
grouped.set(use.actionPath, list);
}
return grouped;
}
function findActionRefChanges(
filePath: string,
previousContent: string,
currentContent: string,
): ActionRefChange[] {
const previousByPath = groupUsesByActionPath(
extractActionUses(previousContent),
);
const currentByPath = groupUsesByActionPath(
extractActionUses(currentContent),
);
const actionPaths = new Set<string>([
...previousByPath.keys(),
...currentByPath.keys(),
]);
const changes: ActionRefChange[] = [];
for (const actionPath of actionPaths) {
const previousUses = previousByPath.get(actionPath) ?? [];
const currentUses = currentByPath.get(actionPath) ?? [];
const pairedCount = Math.min(previousUses.length, currentUses.length);
for (let index = 0; index < pairedCount; index += 1) {
const previousUse = previousUses[index];
const currentUse = currentUses[index];
if (!previousUse || !currentUse) {
continue;
}
if (previousUse.ref === currentUse.ref) {
continue;
}
changes.push({
filePath,
lineNumber: currentUse.lineNumber,
actionPath,
previousRef: previousUse.ref,
currentRef: currentUse.ref,
});
}
if (currentUses.length > previousUses.length) {
for (
let index = previousUses.length;
index < currentUses.length;
index += 1
) {
const currentUse = currentUses[index];
if (!currentUse) {
continue;
}
changes.push({
filePath,
lineNumber: currentUse.lineNumber,
actionPath,
previousRef: null,
currentRef: currentUse.ref,
});
}
}
}
return changes;
}
function getChangedWorkflowYamlFiles(diffRange: string): string[] {
const files = runGitNameOnly(
[
"diff",
"--name-only",
"--diff-filter=ACMR",
diffRange,
"--",
".github/workflows",
".github/actions",
],
ROOT_DIR,
);
return files.filter((filePath) => {
const ext = path.extname(filePath).toLowerCase();
return ext === ".yml" || ext === ".yaml";
});
}
function getFileContentAtRef(ref: string, filePath: string): string | null {
const result = tryGit(["show", `${ref}:${filePath}`]);
if (!result.ok) {
return null;
}
return result.stdout;
}
function validateModifiedActionRefs(
baseRef: string,
headRef: string,
): string[] {
const errors: string[] = [];
const warnings: string[] = [];
const fetched = tryGit([
"fetch",
"--no-tags",
"--depth=200",
"origin",
baseRef,
headRef,
]);
if (!fetched.ok) {
errors.push(
`Unable to fetch refs for action pin checks (${baseRef}, ${headRef}): ${fetched.stderr || "unknown error"}`,
);
return errors;
}
const baseRemoteRef = `origin/${baseRef}`;
const headRemoteRef = `origin/${headRef}`;
const diffRange = `${baseRemoteRef}...${headRemoteRef}`;
const changedFiles = getChangedWorkflowYamlFiles(diffRange);
if (changedFiles.length === 0) {
return errors;
}
const actionRefChanges: ActionRefChange[] = [];
for (const filePath of changedFiles) {
const previousContent = getFileContentAtRef(baseRemoteRef, filePath) ?? "";
const currentContent = getFileContentAtRef(headRemoteRef, filePath);
if (currentContent === null) {
continue;
}
actionRefChanges.push(
...findActionRefChanges(filePath, previousContent, currentContent),
);
}
if (actionRefChanges.length === 0) {
return errors;
}
logInfo(
`Validating ${actionRefChanges.length} modified GitHub Action reference(s) in PR changes.`,
);
for (const change of actionRefChanges) {
if (!SHA_PIN_PATTERN.test(change.currentRef)) {
errors.push(
`${change.filePath}:${change.lineNumber} '${change.actionPath}@${change.currentRef}' must use a full 40-character commit SHA pin.`,
);
continue;
}
if (!change.previousRef) {
continue;
}
try {
const repoSlug = parseActionRepoSlug(change.actionPath);
const { tagToSha, shaToBestVersion } = loadActionRepoData(
repoSlug,
ROOT_DIR,
);
const previousSha = resolveActionRefSha(
repoSlug,
change.previousRef,
tagToSha,
ROOT_DIR,
);
const currentSha = resolveActionRefSha(
repoSlug,
change.currentRef,
tagToSha,
ROOT_DIR,
);
if (!previousSha || !currentSha) {
warnings.push(
`${change.filePath}:${change.lineNumber} could not resolve refs for '${change.actionPath}' to compare version progression.`,
);
continue;
}
const previousVersion = shaToBestVersion.get(previousSha);
const currentVersion = shaToBestVersion.get(currentSha);
if (!previousVersion || !currentVersion) {
warnings.push(
`${change.filePath}:${change.lineNumber} no semver release tags found for '${change.actionPath}' refs; skipping downgrade check.`,
);
continue;
}
if (compareSemVer(currentVersion, previousVersion) < 0) {
errors.push(
`${change.filePath}:${change.lineNumber} '${change.actionPath}' was downgraded from ${formatSemVer(previousVersion)} to ${formatSemVer(currentVersion)}.`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(
`${change.filePath}:${change.lineNumber} failed to validate '${change.actionPath}': ${message}`,
);
}
}
for (const warning of warnings) {
logWarn(warning);
}
return errors;
}
const PROHIBITED_PATHS = [
"docs/api",
"docs/api-md",
];
function getProhibitedPathsChanges(): string[] {
const prohibitedPaths = PROHIBITED_PATHS;
const staged = runGitNameOnly(
["diff", "--name-only", "--cached", "--", ...prohibitedPaths],
ROOT_DIR,
);
const unstaged = runGitNameOnly(
["diff", "--name-only", "--", ...prohibitedPaths],
ROOT_DIR,
);
const untracked = runGitNameOnly(
["ls-files", "--others", "--exclude-standard", "--", ...prohibitedPaths],
ROOT_DIR,
);
return [...new Set([...staged, ...unstaged, ...untracked])].sort(
(left, right) => left.localeCompare(right),
);
}
function getNormalizedRef(ref: string): string {
return ref.trim();
}
function resolveRefs(options: PrCheckOptions): {
baseRef: string;
headRef: string;
} {
const baseRef = getNormalizedRef(
options.baseRef || process.env.GITHUB_BASE_REF || "",
);
const headRef = getNormalizedRef(
options.headRef || process.env.GITHUB_HEAD_REF || "",
);
if (
(baseRef.length > 0 && headRef.length === 0) ||
(headRef.length > 0 && baseRef.length === 0)
) {
fail(
"Both base and head refs must be provided together (flags or environment).",
1,
);
}
return { baseRef, headRef };
}
async function runChecks(options: PrCheckOptions): Promise<string[]> {
const { baseRef, headRef } = resolveRefs(options);
const errors: string[] = [];
const changedPaths = getProhibitedPathsChanges();
if (baseRef.length > 0 && headRef.length > 0) {
if (baseRef === "main" && headRef !== "staging") {
errors.push(
`Merge to 'main' is only allowed from 'staging'. Current source: '${headRef}'.`,
);
errors.push("To fix this, rebase your feature branch onto 'staging'.");
}
errors.push(...validateModifiedActionRefs(baseRef, headRef));
} else {
logInfo("Base/head refs were not provided; skipping branch policy check.");
}
if (changedPaths.length > 0) {
errors.push(
`Detected prohibited path changes in:\n${changedPaths.join(", ")}\n` +
`Please remove changes to these paths before proceeding.\n` +
`These paths are restricted and should not be modified in pull requests.\n` +
`Prohibited paths include:\n -${PROHIBITED_PATHS.join("\n -")}.`,
);
}
return errors;
}
async function shouldAllowAdminBypass(
options: PrCheckOptions,
): Promise<boolean> {
if (!options.allowAdminBypass) {
return false;
}
return isAdminActor(
process.env.GITHUB_ACTOR ?? "",
process.env.GITHUB_REPOSITORY ?? "",
);
}
const prChecksCommand = defineCommand({
description: "Run pull request checks",
options: defineOptions(
z.object({
baseRef: z
.string()
.default("")
.describe("Pull request base branch ref (for example: staging)"),
headRef: z
.string()
.default("")
.describe(
"Pull request head branch ref (for example: feature/my-feature)",
),
allowAdminBypass: z
.boolean()
.default(false)
.describe("Allow bypass when the GitHub actor has admin permission"),
}),
{
b: "baseRef",
h: "headRef",
a: "allowAdminBypass",
},
),
action: async (options: PrCheckOptions) => {
const errors = await runChecks(options);
const allowAdminBypass = await shouldAllowAdminBypass(options);
if (errors.length == 0) {
logSuccess("Pull request checks passed.");
return;
}
for (const error of errors) {
logError(error);
}
if (allowAdminBypass) {
logWarn(
"Admin bypass is allowed for this pull request actor. Treating as warning only.",
);
} else {
fail("Pull request checks failed.");
}
},
});
const cliConfig = createCliConfig({
importMetaUrl: import.meta.url,
description: "Run pull request checks",
commands: {
run: prChecksCommand,
},
defaultCommand: prChecksCommand,
});
await runMain(async () => {
await runCli(cliConfig);
});