mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-11-13 21:36:56 +00:00
* style: set Prettier print width to 110 and reformat Without a repo config, the Prettier CLI and Trunk used the default width of 80, while editors with a user printWidth reflowed files on save, producing formatting-only diffs. Pin the width in .prettierrc.json and reformat source, tests, scripts and JS/TS config files to match. Files that already failed Prettier at width 80 for unrelated reasons (tsconfig*.json, .trunk/*, V2_FAQ.MD) are left untouched. No code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover cache restore and save failure paths Codecov flagged lines in #246 as uncovered patch lines; they were untested before the reformat touched them. Add behavior tests for: - Cache: cache miss, missing or empty manifests, unreadable cache dir, metapackage-only restore, install scripts running after their archive with dpkg actions, existing archives, unavailable cache service, and save failures. - ManifestEntry.fromJSON round trip. - buildFileList dropping missing files, updateAptLists skipping update for populated lists and searching only to max depth, and fromInput ignoring a version without a package name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
417 lines
12 KiB
TypeScript
Executable file
417 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);
|
|
});
|