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>
This commit is contained in:
awalsh128 2026-11-09 11:15:05 -08:00
parent e8bec41895
commit 64a160d1cc
32 changed files with 309 additions and 1117 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
# GitHub Sponsors configuration
github: awalsh128
github: awalsh128

View file

@ -1,10 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**Is your feature request related to a problem? Please describe.**

3
.prettierrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 110
}

View file

@ -5,12 +5,7 @@ export default [
ignores: ["dist/**", "node_modules/**", "target/**"],
},
{
files: [
"src/**/*.ts",
"test/**/*.ts",
"scripts/**/*.ts",
"scripts/**/*.mts",
],
files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "scripts/**/*.mts"],
languageOptions: {
parser: tsParser,
ecmaVersion: "latest",

View file

@ -42,8 +42,7 @@ export default {
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json", "package-lock.json"],
message:
"chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
},
],

View file

@ -50,13 +50,10 @@ export default {
// Dependencies trigger these and they are harmless and very noisy:
// top level `this` in ES modules, misplaced /*@__PURE__*/ comments and
// circular imports inside third-party packages.
const fromDependency = (warning.ids ?? [warning.id ?? ""]).every((id) =>
id.includes("node_modules"),
);
const fromDependency = (warning.ids ?? [warning.id ?? ""]).every((id) => id.includes("node_modules"));
if (
warning.code === "THIS_IS_UNDEFINED" ||
(fromDependency &&
["INVALID_ANNOTATION", "CIRCULAR_DEPENDENCY"].includes(warning.code))
(fromDependency && ["INVALID_ANNOTATION", "CIRCULAR_DEPENDENCY"].includes(warning.code))
) {
return;
}

View file

@ -26,10 +26,7 @@ function toLog(result: LogResult): string {
}
/** Determine exit code: use code if available, else map signal to 137 (SIGKILL) or 143 (SIGTERM) */
function getFinalExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
function getFinalExitCode(code: number | null, signal: NodeJS.Signals | null): number {
if (code !== null) {
return code;
} else if (signal === "SIGTERM") {
@ -127,11 +124,7 @@ async function execute(cmdLine: string): Promise<LogResult> {
});
}
async function writeLogFile(
baseDir: string,
relativePath: string,
result: LogResult,
): Promise<string> {
async function writeLogFile(baseDir: string, relativePath: string, result: LogResult): Promise<string> {
const outputPath = resolve(baseDir, `${relativePath}.log`);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, toLog(result), "utf8");
@ -172,30 +165,25 @@ async function main(): Promise<void> {
const testLogFiles: TestLogEntry[] = [
{
filepath: "autoclean",
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoclean",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoclean",
},
{
filepath: "autoremove_found",
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
},
{
filepath: "autoremove_notfound",
preExecCmdLine: ["apt-get autoremove -y"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 autoremove",
},
{
filepath: "cacheshow_mixedfoundnotfound",
execCmdLine:
"/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3 nonexistentpackage",
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3 nonexistentpackage",
},
{
filepath: "cacheshow_multiplefound",
execCmdLine:
"/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3",
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show xdot python3",
},
{
filepath: "cacheshow_singlefound",
@ -203,8 +191,7 @@ const testLogFiles: TestLogEntry[] = [
},
{
filepath: "cacheshow_singlenotfound",
execCmdLine:
"/usr/bin/apt-cache --quiet=0 --no-all-versions show nonexistentpackage",
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show nonexistentpackage",
},
{
filepath: "cacheshow_virtualpackage",
@ -212,8 +199,7 @@ const testLogFiles: TestLogEntry[] = [
},
{
filepath: "cacheshow_virtualandnotpackages",
execCmdLine:
"/usr/bin/apt-cache --quiet=0 --no-all-versions show libvips xdot",
execCmdLine: "/usr/bin/apt-cache --quiet=0 --no-all-versions show libvips xdot",
},
{
filepath: "cacheshowpkg_virtualpackage",
@ -222,35 +208,26 @@ const testLogFiles: TestLogEntry[] = [
{
filepath: "install_mixedfoundnotfound",
preExecCmdLine: ["apt-get remove -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot nonexistentpackage",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot nonexistentpackage",
},
{
filepath: "install_mixedinstallstatus",
preExecCmdLine: [
"apt-get install -y xdot",
"apt-get remove -y rolldice",
"apt-get autoremove -y",
],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f rolldice xdot",
preExecCmdLine: ["apt-get install -y xdot", "apt-get remove -y rolldice", "apt-get autoremove -y"],
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f rolldice xdot",
},
{
filepath: "install_singleinstalled",
preExecCmdLine: ["apt-get install -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
},
{
filepath: "install_singlenotfound",
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f nonexistentpackage",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f nonexistentpackage",
},
{
filepath: "install_singlenotinstalled",
preExecCmdLine: ["apt-get remove -y xdot", "apt-get autoremove -y"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install -f xdot",
},
{
filepath: "listinstalled",
@ -296,8 +273,7 @@ const testLogFiles: TestLogEntry[] = [
{
filepath: "remove_singlenotinstalled",
preExecCmdLine: ["apt-get remove -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
},
{
filepath: "remove_singlenotfound",
@ -307,8 +283,7 @@ const testLogFiles: TestLogEntry[] = [
{
filepath: "remove_singleinstalled",
preExecCmdLine: ["apt-get install -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 remove -f xdot --autoremove",
},
{
filepath: "search_multiplefound",
@ -329,8 +304,7 @@ const testLogFiles: TestLogEntry[] = [
{
filepath: "upgrade_mixedfoundnotfound",
preExecCmdLine: ["apt-get remove -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot nonexistentpackage",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot nonexistentpackage",
},
{
filepath: "upgrade_mixedupgradestatus",
@ -339,42 +313,29 @@ const testLogFiles: TestLogEntry[] = [
"apt-get remove -y xxd",
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd xdot",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd xdot",
},
{
filepath: "upgrade_singleupgraded",
preExecCmdLine: ["apt-get upgrade -y xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 upgrade",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 upgrade",
},
{
filepath: "upgrade_singlenotfound",
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install nonexistentpackage",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install nonexistentpackage",
},
{
filepath: "upgrade_singlenotupgraded",
preExecCmdLine: [
"apt-get remove -y xxd",
"apt-get install -y xxd=2:8.1.2269-1ubuntu5.30",
],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd",
preExecCmdLine: ["apt-get remove -y xxd", "apt-get install -y xxd=2:8.1.2269-1ubuntu5.30"],
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xxd",
},
{
filepath: "upgrade_singleinstalled",
preExecCmdLine: ["apt-get install xdot"],
execCmdLine:
"/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot",
execCmdLine: "/usr/bin/apt-get --quiet=0 -y -o DPkg::Lock::Timeout=-1 install xdot",
},
];
await main().catch((error) => {
fail(
"Error occurred during log creation:\n" +
String(error) +
"\n" +
(error.stack || ""),
);
fail("Error occurred during log creation:\n" + String(error) + "\n" + (error.stack || ""));
});

View file

@ -17,13 +17,7 @@
*/
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { defineCommand, defineOptions } from "@robingenz/zli";
@ -79,12 +73,7 @@ type SettingsFile = {
type BypassActor = {
actor_id: number;
actor_type:
| "RepositoryRole"
| "Team"
| "Integration"
| "OrganizationAdmin"
| "DeployKey";
actor_type: "RepositoryRole" | "Team" | "Integration" | "OrganizationAdmin" | "DeployKey";
bypass_mode: "always" | "pull_request";
};
@ -194,11 +183,7 @@ function ensureDirForFile(filePath: string): string {
return absPath;
}
function writeJsonFile(
filePath: string,
payload: unknown,
successLabel: string,
): void {
function writeJsonFile(filePath: string, payload: unknown, successLabel: string): void {
const absPath = ensureDirForFile(filePath);
writeFileSync(absPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
logSuccess(`Wrote ${successLabel} to ${absPath}`);
@ -223,11 +208,7 @@ function writeTempPayload(payload: unknown, prefix: string): string {
return payloadFile;
}
function withTempPayload<T>(
payload: unknown,
prefix: string,
fn: (payloadFile: string) => T,
): T {
function withTempPayload<T>(payload: unknown, prefix: string, fn: (payloadFile: string) => T): T {
const tempDir = mkdtempSync(path.join(tmpdir(), prefix));
const payloadFile = path.join(tempDir, "payload.json");
@ -291,12 +272,7 @@ function downloadSettings(owner: string, repo: string, filePath: string): void {
writeJsonFile(filePath, payload, "repository settings");
}
function uploadSettings(
owner: string,
repo: string,
filePath: string,
dryRun: boolean,
): void {
function uploadSettings(owner: string, repo: string, filePath: string, dryRun: boolean): void {
const settingsFile = readSettingsFile(filePath);
const payload = buildPatchPayload(settingsFile.settings);
@ -314,14 +290,7 @@ function uploadSettings(
}
withTempPayload(payload, "ts-apt-repo-settings-", (payloadFile) => {
runGh([
"api",
"-X",
"PATCH",
`repos/${owner}/${repo}`,
"--input",
payloadFile,
]);
runGh(["api", "-X", "PATCH", `repos/${owner}/${repo}`, "--input", payloadFile]);
});
logSuccess(`Uploaded repository settings to ${owner}/${repo}`);
@ -342,24 +311,17 @@ function fetchRulesets(owner: string, repo: string): Ruleset[] {
const list = JSON.parse(listRaw) as Array<{ id: number }>;
return list.map((entry) => {
const detailRaw = runGh([
"api",
`/repos/${owner}/${repo}/rulesets/${entry.id}`,
]);
const detailRaw = runGh(["api", `/repos/${owner}/${repo}/rulesets/${entry.id}`]);
return JSON.parse(detailRaw) as Ruleset;
});
}
function mergeBypassActors(
ruleset: Ruleset,
appsBypassActors: AppBypassConfig[],
): BypassActor[] {
function mergeBypassActors(ruleset: Ruleset, appsBypassActors: AppBypassConfig[]): BypassActor[] {
const desiredActors = [...ruleset.bypass_actors];
for (const cfg of appsBypassActors) {
const exists = desiredActors.some(
(actor) =>
actor.actor_id === cfg.appId && actor.actor_type === "Integration",
(actor) => actor.actor_id === cfg.appId && actor.actor_type === "Integration",
);
if (exists) {
@ -416,12 +378,7 @@ function downloadRulesets(owner: string, repo: string, filePath: string): void {
logInfo(`Found ${rulesets.length} ruleset(s).`);
}
function uploadRulesets(
owner: string,
repo: string,
filePath: string,
dryRun: boolean,
): void {
function uploadRulesets(owner: string, repo: string, filePath: string, dryRun: boolean): void {
const config = readRulesetsFile(filePath);
if (config.rulesets.length === 0) {
@ -431,9 +388,7 @@ function uploadRulesets(
logInfo(`Fetching current rulesets for ${owner}/${repo}...`);
const remoteRulesets = fetchRulesets(owner, repo);
const remoteById = new Map(
remoteRulesets.map((ruleset) => [ruleset.id, ruleset]),
);
const remoteById = new Map(remoteRulesets.map((ruleset) => [ruleset.id, ruleset]));
if (remoteRulesets.length === 0) {
logWarn("No rulesets found in the repository.");
@ -448,37 +403,22 @@ function uploadRulesets(
}
const payload = buildRulesetPutPayload(ruleset, config.appsBypassActors);
logInfo(
`Ruleset "${ruleset.name}" (${ruleset.id}): syncing full definition...`,
);
logInfo(`Ruleset "${ruleset.name}" (${ruleset.id}): syncing full definition...`);
if (dryRun) {
logInfo(
`Dry-run mode: would PUT with ${JSON.stringify(payload, null, 2)}`,
);
logInfo(`Dry-run mode: would PUT with ${JSON.stringify(payload, null, 2)}`);
continue;
}
runGh(
[
"api",
"-X",
"PUT",
`/repos/${owner}/${repo}/rulesets/${ruleset.id}`,
"--input",
"-",
],
["api", "-X", "PUT", `/repos/${owner}/${repo}/rulesets/${ruleset.id}`, "--input", "-"],
JSON.stringify(payload),
);
logSuccess(`Ruleset "${ruleset.name}" (${ruleset.id}) synchronized.`);
}
logSuccess(
dryRun
? "Dry-run complete. No remote changes were made."
: "Rulesets applied successfully.",
);
logSuccess(dryRun ? "Dry-run complete. No remote changes were made." : "Rulesets applied successfully.");
}
function readVariablesFile(filePath: string): VariablesFile {
@ -500,11 +440,7 @@ function fetchVariables(owner: string, repo: string): RepoVariable[] {
.sort((left, right) => left.name.localeCompare(right.name));
}
function downloadVariables(
owner: string,
repo: string,
filePath: string,
): void {
function downloadVariables(owner: string, repo: string, filePath: string): void {
logInfo(`Downloading repository variables for ${owner}/${repo}...`);
const payload: VariablesFile = {
owner,
@ -516,17 +452,10 @@ function downloadVariables(
writeJsonFile(filePath, payload, "repository variables");
}
function uploadVariables(
owner: string,
repo: string,
filePath: string,
dryRun: boolean,
): void {
function uploadVariables(owner: string, repo: string, filePath: string, dryRun: boolean): void {
const config = readVariablesFile(filePath);
const remoteVariables = fetchVariables(owner, repo);
const remoteByName = new Map(
remoteVariables.map((variable) => [variable.name, variable]),
);
const remoteByName = new Map(remoteVariables.map((variable) => [variable.name, variable]));
if (config.variables.length === 0) {
logWarn("No repository variables configured; nothing to apply.");
@ -540,14 +469,7 @@ function uploadVariables(
logInfo(`Repository variable "${variable.name}": creating...`);
if (!dryRun) {
runGh(
[
"api",
"-X",
"POST",
`/repos/${owner}/${repo}/actions/variables`,
"--input",
"-",
],
["api", "-X", "POST", `/repos/${owner}/${repo}/actions/variables`, "--input", "-"],
JSON.stringify(variable),
);
}
@ -562,23 +484,14 @@ function uploadVariables(
logInfo(`Repository variable "${variable.name}": updating...`);
if (!dryRun) {
runGh(
[
"api",
"-X",
"PATCH",
`/repos/${owner}/${repo}/actions/variables/${variable.name}`,
"--input",
"-",
],
["api", "-X", "PATCH", `/repos/${owner}/${repo}/actions/variables/${variable.name}`, "--input", "-"],
JSON.stringify({ value: variable.value }),
);
}
}
logSuccess(
dryRun
? "Dry-run complete. No remote changes were made."
: "Repository variables applied successfully.",
dryRun ? "Dry-run complete. No remote changes were made." : "Repository variables applied successfully.",
);
}
@ -615,12 +528,7 @@ function downloadTags(owner: string, repo: string, filePath: string): void {
writeJsonFile(filePath, payload, "repository tags");
}
function uploadTags(
owner: string,
repo: string,
filePath: string,
dryRun: boolean,
): void {
function uploadTags(owner: string, repo: string, filePath: string, dryRun: boolean): void {
const config = readTagsFile(filePath);
const remoteTags = fetchTags(owner, repo);
const remoteByName = new Map(remoteTags.map((tag) => [tag.name, tag]));
@ -637,14 +545,7 @@ function uploadTags(
logInfo(`Repository tag "${tag.name}": creating at ${tag.sha}...`);
if (!dryRun) {
runGh(
[
"api",
"-X",
"POST",
`/repos/${owner}/${repo}/git/refs`,
"--input",
"-",
],
["api", "-X", "POST", `/repos/${owner}/${repo}/git/refs`, "--input", "-"],
JSON.stringify({ ref: `refs/tags/${tag.name}`, sha: tag.sha }),
);
}
@ -663,16 +564,11 @@ function uploadTags(
}
logSuccess(
dryRun
? "Dry-run complete. No remote changes were made."
: "Repository tags applied successfully.",
dryRun ? "Dry-run complete. No remote changes were made." : "Repository tags applied successfully.",
);
}
function resolveFilePath(
target: Exclude<SyncTarget, "all">,
fileOverride?: string,
): string {
function resolveFilePath(target: Exclude<SyncTarget, "all">, fileOverride?: string): string {
return fileOverride?.trim() || DEFAULT_TARGET_PATHS[target];
}
@ -730,17 +626,10 @@ function applyTargets(
): void {
if (target === "all") {
if (fileOverride?.trim()) {
fail(
"--file cannot be used with --target all. Use the default target files instead.",
);
fail("--file cannot be used with --target all. Use the default target files instead.");
}
for (const currentTarget of [
"settings",
"rulesets",
"vars",
"tags",
] as const) {
for (const currentTarget of ["settings", "rulesets", "vars", "tags"] as const) {
applyTarget(action, currentTarget, owner, repo, undefined, dryRun);
}
return;
@ -774,14 +663,8 @@ const sharedOptions = defineOptions(
.enum(["settings", "rulesets", "vars", "tags", "all"])
.default("all")
.describe("Metadata target to sync"),
file: z
.string()
.optional()
.describe("Override the default JSON file path for a single target"),
dryRun: z
.boolean()
.default(false)
.describe("Print changes without applying them"),
file: z.string().optional().describe("Override the default JSON file path for a single target"),
dryRun: z.boolean().default(false).describe("Print changes without applying them"),
}),
{ o: "owner", r: "repo", t: "target", f: "file", d: "dryRun" },
);
@ -790,14 +673,7 @@ const downloadCommand = defineCommand({
description: "Download GitHub repository metadata to tracked JSON files",
options: sharedOptions,
action: async (options) => {
applyTargets(
"download",
options.target,
options.owner,
options.repo,
options.file,
options.dryRun,
);
applyTargets("download", options.target, options.owner, options.repo, options.file, options.dryRun);
},
});
@ -805,14 +681,7 @@ const uploadCommand = defineCommand({
description: "Upload GitHub repository metadata from tracked JSON files",
options: sharedOptions,
action: async (options) => {
applyTargets(
"upload",
options.target,
options.owner,
options.repo,
options.file,
options.dryRun,
);
applyTargets("upload", options.target, options.owner, options.repo, options.file, options.dryRun);
},
});

View file

@ -20,22 +20,12 @@ import {
type BranchName = string;
function branchExistsOnOrigin(branch: BranchName): boolean {
return tryRun(
"git",
["ls-remote", "--exit-code", "--heads", "origin", branch],
{ cwd: ROOT_DIR },
).ok;
return tryRun("git", ["ls-remote", "--exit-code", "--heads", "origin", branch], { cwd: ROOT_DIR }).ok;
}
function createOrCheckoutBranch(
issueId: string,
checkoutBranch: BranchName,
baseBranch: BranchName,
): void {
function createOrCheckoutBranch(issueId: string, checkoutBranch: BranchName, baseBranch: BranchName): void {
if (branchExistsOnOrigin(checkoutBranch)) {
logInfo(
`Branch ${checkoutBranch} exists. Checking out and merging ${baseBranch}...`,
);
logInfo(`Branch ${checkoutBranch} exists. Checking out and merging ${baseBranch}...`);
run("git", ["checkout", checkoutBranch], { cwd: ROOT_DIR });
run("git", ["pull", "origin", checkoutBranch], { cwd: ROOT_DIR });
run("git", ["merge", baseBranch], { cwd: ROOT_DIR });
@ -64,12 +54,7 @@ function commitIfNeeded(message: string): void {
process.exit(commit.status ?? 1);
}
function pushChanges(
issueId: string,
fixType: string,
syncBranch: BranchName,
syncBase: BranchName,
): void {
function pushChanges(issueId: string, fixType: string, syncBranch: BranchName, syncBase: BranchName): void {
const message = `${fixType}: resolve critical production issue in #${issueId}`;
commitIfNeeded(message);
@ -96,20 +81,9 @@ function pushChanges(
logSuccess(`PR already exists: ${prUrl}`);
} else {
logInfo("No PR found. Creating new PR...");
run(
"gh",
[
"pr",
"create",
"--head",
syncBranch,
"--base",
syncBase,
"--title",
message,
],
{ cwd: ROOT_DIR },
);
run("gh", ["pr", "create", "--head", syncBranch, "--base", syncBase, "--title", message], {
cwd: ROOT_DIR,
});
}
logInfo(`Pushing changes from ${syncBase} to ${syncBranch}...`);
@ -118,10 +92,7 @@ function pushChanges(
const hotfixCommand = defineCommand({
description: "Create or update hotfix and sync branches for an issue",
args: z.tuple([
z.string().describe("Issue ID"),
z.string().describe("Target branch"),
]),
args: z.tuple([z.string().describe("Issue ID"), z.string().describe("Target branch")]),
action: async (_options, args) => {
const [issueId, base] = args;

View file

@ -63,22 +63,18 @@ function listYamlFiles(rootPath: string): string[] {
}
function buildReferenceSpecs(nodeMajor: string): ReferenceSpec[] {
const yamlReferenceSpecs: ReferenceSpec[] = listYamlFiles(rootDir).map(
(filePath) => ({
path: filePath,
replacements: [
[/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`],
[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`],
],
}),
);
const yamlReferenceSpecs: ReferenceSpec[] = listYamlFiles(rootDir).map((filePath) => ({
path: filePath,
replacements: [
[/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`],
[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`],
],
}));
return [
{
path: `${rootDir}/package.json`,
replacements: [
[/"node":\s*">=\d+(?:\.\d+\.\d+)?"/g, `"node": ">=${nodeMajor}"`],
],
replacements: [[/"node":\s*">=\d+(?:\.\d+\.\d+)?"/g, `"node": ">=${nodeMajor}"`]],
},
{
path: `${rootDir}/scripts/devopslib.mts`,
@ -97,10 +93,7 @@ function buildReferenceSpecs(nodeMajor: string): ReferenceSpec[] {
];
}
function applyReplacements(
content: string,
replacements: ReferenceSpec["replacements"],
): string {
function applyReplacements(content: string, replacements: ReferenceSpec["replacements"]): string {
let updated = content;
for (const [pattern, replacement] of replacements) {
updated = updated.replace(pattern, replacement);
@ -160,9 +153,7 @@ async function getAvailableNodeMajors(): Promise<string[]> {
});
if (!response.ok) {
fail(
`Unable to read Node.js versions from npm registry: HTTP ${response.status}`,
);
fail(`Unable to read Node.js versions from npm registry: HTTP ${response.status}`);
}
packageJson = (await response.json()) as NodeRegistryPackage;
@ -179,9 +170,7 @@ async function getAvailableNodeMajors(): Promise<string[]> {
return [
...new Set(
versions
.map((version) => String(version).split(".")[0] ?? "")
.filter((major) => major.length > 0),
versions.map((version) => String(version).split(".")[0] ?? "").filter((major) => major.length > 0),
),
]
.filter((major) => /^\d+$/.test(major))
@ -206,15 +195,11 @@ function runScriptWithOptionalSudo(scriptText: string): void {
});
if (result.error) {
throw new Error(
`Failed to execute NodeSource setup script: ${result.error.message}`,
);
throw new Error(`Failed to execute NodeSource setup script: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`NodeSource setup script failed with exit code ${result.status ?? "unknown"}.`,
);
throw new Error(`NodeSource setup script failed with exit code ${result.status ?? "unknown"}.`);
}
}
@ -233,9 +218,7 @@ async function setupNodeSourceRepo(nodeMajor: string): Promise<void> {
});
if (!response.ok) {
fail(
`Unable to download NodeSource setup script: HTTP ${response.status}`,
);
fail(`Unable to download NodeSource setup script: HTTP ${response.status}`);
}
scriptText = await response.text();
@ -273,14 +256,10 @@ function installNodejs(nodeMajor: string): void {
const fullNodeVersion = runCaptureOutput("node", ["-v"], { cwd: rootDir });
if (!fullNodeVersion.startsWith(`v${nodeMajor}.`)) {
fail(
`Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`,
);
fail(`Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`);
}
logSuccess(
`Node.js ${fullNodeVersion} installed/updated for major ${nodeMajor}.`,
);
logSuccess(`Node.js ${fullNodeVersion} installed/updated for major ${nodeMajor}.`);
}
function verifyInstalledNodeMajor(nodeMajor: string): void {
@ -291,9 +270,7 @@ function verifyInstalledNodeMajor(nodeMajor: string): void {
const fullNodeVersion = String(nodeResult.stdout ?? "").trim();
if (!fullNodeVersion.startsWith(`v${nodeMajor}.`)) {
fail(
`Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`,
);
fail(`Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`);
}
logSuccess(`Installed Node.js version matches major ${nodeMajor}.`);
@ -302,14 +279,8 @@ function verifyInstalledNodeMajor(nodeMajor: string): void {
const nodeVersionOptions = defineOptions(
z
.object({
verify: z
.boolean()
.default(false)
.describe("Verify version references and installed Node.js"),
update: z
.boolean()
.default(false)
.describe("Update version references and install Node.js"),
verify: z.boolean().default(false).describe("Verify version references and installed Node.js"),
update: z.boolean().default(false).describe("Update version references and install Node.js"),
})
.refine((options) => options.verify !== options.update, {
message: "Specify exactly one of --verify or --update.",

View file

@ -35,13 +35,7 @@ type SetupDevEnvOptions = {
ide: SupportedIde;
};
const SUPPORTED_IDES: SupportedIde[] = [
"vscode",
"cursor",
"windsurf",
"zed",
"jetbrains",
];
const SUPPORTED_IDES: SupportedIde[] = ["vscode", "cursor", "windsurf", "zed", "jetbrains"];
const IDE_TARGET_DIRS: Record<SupportedIde, string> = {
vscode: ".vscode",
@ -59,11 +53,7 @@ const IDE_SETTINGS_RELPATHS: Partial<Record<SupportedIde, string>> = {
};
const AGENTS_MD_SETTING_KEY = "chat.useAgentsMdFile";
const AGENTS_MD_SUPPORTED_IDES: ReadonlySet<SupportedIde> = new Set([
"vscode",
"cursor",
"windsurf",
]);
const AGENTS_MD_SUPPORTED_IDES: ReadonlySet<SupportedIde> = new Set(["vscode", "cursor", "windsurf"]);
type JsonPrimitive = null | boolean | number | string;
interface JsonArray extends Array<JsonLike> {}
@ -76,10 +66,7 @@ function isJsonRecord(value: unknown): value is JsonRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function mergeJsonArrays(
existing: JsonLike[],
template: JsonLike[],
): JsonLike[] {
function mergeJsonArrays(existing: JsonLike[], template: JsonLike[]): JsonLike[] {
const merged = [...existing];
const seen = new Set(existing.map((item) => JSON.stringify(item)));
@ -94,10 +81,7 @@ function mergeJsonArrays(
return merged;
}
function mergeJsonWithTemplate(
existing: JsonLike,
template: JsonLike,
): JsonLike {
function mergeJsonWithTemplate(existing: JsonLike, template: JsonLike): JsonLike {
if (Array.isArray(existing) && Array.isArray(template)) {
return mergeJsonArrays(existing, template);
}
@ -123,13 +107,7 @@ function mergeJsonWithTemplate(
}
function applyIdeTemplate(ide: SupportedIde): void {
const templateDir = path.join(
ROOT_DIR,
"scripts",
"dev",
"ide_templates",
ide,
);
const templateDir = path.join(ROOT_DIR, "scripts", "dev", "ide_templates", ide);
if (!existsSync(templateDir)) {
fail(`IDE template directory does not exist: ${templateDir}`);
}
@ -187,14 +165,7 @@ function ensureIdeSettingsFile(ide: SupportedIde): string | null {
const ideSettingsDir = path.dirname(ideSettingsPath);
ensureDirExists(ideSettingsDir);
const templateSettingsPath = path.join(
ROOT_DIR,
"scripts",
"dev",
"ide_templates",
ide,
"settings.json",
);
const templateSettingsPath = path.join(ROOT_DIR, "scripts", "dev", "ide_templates", ide, "settings.json");
if (!existsSync(templateSettingsPath)) {
if (!existsSync(ideSettingsPath)) {
@ -213,10 +184,7 @@ function ensureIdeSettingsFile(ide: SupportedIde): string | null {
}
const existingSettingsJson = readJsonFile(ideSettingsPath) as JsonLike;
const mergedSettingsJson = mergeJsonWithTemplate(
existingSettingsJson,
templateSettingsJson,
);
const mergedSettingsJson = mergeJsonWithTemplate(existingSettingsJson, templateSettingsJson);
writeJsonFile(ideSettingsPath, mergedSettingsJson);
return ideSettingsPath;
@ -237,9 +205,7 @@ function ensureAgentsFilesUsed(ide: SupportedIde): void {
if (!ideSettingsJson[AGENTS_MD_SETTING_KEY]) {
ideSettingsJson[AGENTS_MD_SETTING_KEY] = true;
writeJsonFile(ideSettingsPath, ideSettingsJson);
logWarn(
`Updated ${ideSettingsPath} to enable chat.useAgentsMdFile for AGENTS.md support.`,
);
logWarn(`Updated ${ideSettingsPath} to enable chat.useAgentsMdFile for AGENTS.md support.`);
}
}
@ -260,9 +226,7 @@ function ensureAptDependencies(): void {
const missingPackages = [
...new Set(
aptPackages
.filter(([command]) => !commandExists(command))
.map(([, packageName]) => packageName),
aptPackages.filter(([command]) => !commandExists(command)).map(([, packageName]) => packageName),
),
];
@ -282,23 +246,17 @@ function ensureAptDependencies(): void {
function ensureNodeDependencies(nodeVersionMajor: string): void {
if (!commandExists("npm")) {
fail(
`npm is required but not installed. Install Node.js >=${nodeVersionMajor} first.`,
);
fail(`npm is required but not installed. Install Node.js >=${nodeVersionMajor} first.`);
}
// Fast deterministic install path aligned with lockfile-based CI behavior.
logInfo(
"Installing npm dependencies (including devDependencies) via npm ci...",
);
logInfo("Installing npm dependencies (including devDependencies) via npm ci...");
run("npm", ["ci", "--include=dev"]);
}
function warnIfDockerMissing(): void {
if (commandExists("docker")) {
logSuccess(
"Docker CLI detected for devcontainer-backed integration tests.",
);
logSuccess("Docker CLI detected for devcontainer-backed integration tests.");
return;
}
@ -370,14 +328,10 @@ function ensureAuditRemediation(): void {
);
runAuditFix(false);
counts = getAuditCounts();
logInfo(
`Audit after npm audit fix: high=${counts.high}, critical=${counts.critical}.`,
);
logInfo(`Audit after npm audit fix: high=${counts.high}, critical=${counts.critical}.`);
if (counts.high === 0 && counts.critical === 0) {
logSuccess(
"High/critical npm vulnerabilities remediated with npm audit fix.",
);
logSuccess("High/critical npm vulnerabilities remediated with npm audit fix.");
return;
}
@ -386,9 +340,7 @@ function ensureAuditRemediation(): void {
);
runAuditFix(true);
counts = getAuditCounts();
logInfo(
`Audit after npm audit fix --force: high=${counts.high}, critical=${counts.critical}.`,
);
logInfo(`Audit after npm audit fix --force: high=${counts.high}, critical=${counts.critical}.`);
if (counts.high > 0 || counts.critical > 0) {
fail(
@ -414,9 +366,7 @@ async function main(options: SetupDevEnvOptions): Promise<void> {
logInfo("Ensuring required APT dependencies are installed...");
ensureAptDependencies();
logInfo("Checking optional Docker support...");
logInfo(
`Setting up development environment for Node.js v${nodeVersionMajor}.x...`,
);
logInfo(`Setting up development environment for Node.js v${nodeVersionMajor}.x...`);
ensureNodeDependencies(nodeVersionMajor);
ensureAuditRemediation();
warnIfDockerMissing();

View file

@ -1,18 +1,7 @@
#!/usr/bin/env -S node --experimental-strip-types
import {
accessSync,
constants,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import {
execFileSync,
spawnSync,
type ExecFileSyncOptions,
type SpawnSyncOptions,
} from "node:child_process";
import { accessSync, constants, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync, type ExecFileSyncOptions, type SpawnSyncOptions } from "node:child_process";
import {
defineConfig,
processConfig,
@ -90,9 +79,7 @@ export function fail(message: string, exitCode = 1, params: LogParams = {}) {
}
export function log(message: string, params: LogParams = {}) {
console.log(
`${" ".repeat(params.indent ?? 0)}${params.color ?? ""}${message}${COLORS.reset}`,
);
console.log(`${" ".repeat(params.indent ?? 0)}${params.color ?? ""}${message}${COLORS.reset}`);
}
export function logInfo(message: string, params: LogParams = {}) {
@ -111,11 +98,7 @@ export function logError(message: string, params: LogParams = {}) {
log(`❌ ${message}`, params);
}
export function assertNonEmpty(
value: string,
message: string,
usageMessage?: string,
) {
export function assertNonEmpty(value: string, message: string, usageMessage?: string) {
if (!value || value.trim().length === 0) {
if (usageMessage) {
console.error(usageMessage);
@ -140,9 +123,7 @@ export function commandExists(command: string) {
const pathValue = process.env.PATH ?? "";
const dirs = pathValue.split(path.delimiter).filter(Boolean);
return dirs.some((dirPath: string) =>
isExecutable(path.join(dirPath, command)),
);
return dirs.some((dirPath: string) => isExecutable(path.join(dirPath, command)));
}
export function ensureDirExists(dirPath: string) {
@ -183,9 +164,7 @@ export function readNodeMajorVersion(rootDir = ROOT_DIR): string {
fail(".node_ver must contain exactly one non-empty line.");
}
if (!/^\d+$/.test(rawVersion)) {
fail(
".node_ver must contain only the Node.js major version (e.g. 20, 24).",
);
fail(".node_ver must contain only the Node.js major version (e.g. 20, 24).");
}
return rawVersion;
@ -249,9 +228,7 @@ export function run(
return execFileSync(command, args, options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to execute '${command} ${args.join(" ")}'. ${message}`,
);
throw new Error(`Failed to execute '${command} ${args.join(" ")}'. ${message}`);
}
}
@ -264,9 +241,7 @@ export function tryRun(
const result = spawnSync(command, args, options);
if (result.error) {
throw new Error(
`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`,
);
throw new Error(`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`);
}
return {
@ -286,9 +261,7 @@ export function runCaptureOutput(
const result = spawnSync(command, args, normalizedOptions);
if (result.error) {
throw new Error(
`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`,
);
throw new Error(`Failed to spawn '${command} ${args.join(" ")}'. ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
@ -299,10 +272,7 @@ export function runCaptureOutput(
return String(result.stdout ?? "").trim();
}
type CliAction = (
options: unknown,
args: string[],
) => unknown | Promise<unknown>;
type CliAction = (options: unknown, args: string[]) => unknown | Promise<unknown>;
type CliResult = ProcessResult<CommandDefinition<any, any>>;
@ -340,9 +310,7 @@ export function createCliConfig(params: CliConfigParams) {
}
}
fail(
"createCliConfig requires either a non-empty 'name' or a valid 'importMetaUrl'.",
);
fail("createCliConfig requires either a non-empty 'name' or a valid 'importMetaUrl'.");
})();
return defineConfig({
@ -356,19 +324,13 @@ export function createCliConfig(params: CliConfigParams) {
});
}
export async function runCli(
cliConfig: CliConfig,
argv: string[] = process.argv.slice(2),
): Promise<void> {
export async function runCli(cliConfig: CliConfig, argv: string[] = process.argv.slice(2)): Promise<void> {
const result = processConfig(cliConfig, argv) as CliResult;
const commandResult = result as unknown as CliResultCommand;
await commandResult.command.action(commandResult.options, commandResult.args);
}
export async function runMain(
action: () => void | Promise<void>,
exitCode = 1,
): Promise<void> {
export async function runMain(action: () => void | Promise<void>, exitCode = 1): Promise<void> {
try {
await action();
} catch (error) {
@ -395,9 +357,7 @@ export async function confirmPrompt(
.replace(/\\([^\\])/g, "$1");
while (true) {
const answer = await rl
.question(
`${message} [${getText(yesPattern)} | ${getText(noPattern)}] `,
)
.question(`${message} [${getText(yesPattern)} | ${getText(noPattern)}] `)
.then((ans: string) => ans.trim().toLowerCase());
if (yesPattern.test(answer) || answer.trim() === "") {
return;

View file

@ -1,14 +1,6 @@
#!/usr/bin/env -S node --experimental-strip-types
import {
fail,
logInfo,
logSuccess,
logError,
readJsonFile,
ROOT_DIR,
REPO_SLUG,
} from "../devopslib.mts";
import { fail, logInfo, logSuccess, logError, readJsonFile, ROOT_DIR, REPO_SLUG } from "../devopslib.mts";
type ErrorCollector = {
errors: string[];
@ -18,9 +10,7 @@ type ErrorCollector = {
type JsonRecord = Record<string, unknown>;
const packageJson = readJsonFile(`${ROOT_DIR}/package.json`) as JsonRecord;
const vscodeSettingsJson = readJsonFile(
`${ROOT_DIR}/.vscode/settings.json`,
) as JsonRecord;
const vscodeSettingsJson = readJsonFile(`${ROOT_DIR}/.vscode/settings.json`) as JsonRecord;
const tsconfigJson = readJsonFile(`${ROOT_DIR}/tsconfig.json`) as JsonRecord;
const typedocJson = readJsonFile(`${ROOT_DIR}/typedoc.json`) as JsonRecord;
@ -68,10 +58,7 @@ function tokenizePath(pathExpression: string): Array<string | number> {
return tokens;
}
function getByPath(
root: unknown,
pathExpression: string,
): { found: boolean; value: unknown } {
function getByPath(root: unknown, pathExpression: string): { found: boolean; value: unknown } {
const tokens = tokenizePath(pathExpression);
let cursor = root;
@ -134,9 +121,7 @@ function expectFieldType(
const actualType = valueType(result.value);
if (actualType !== expectedType) {
addError(
`field '${fieldPath}' type '${actualType}' does not match expected '${expectedType}'`,
);
addError(`field '${fieldPath}' type '${actualType}' does not match expected '${expectedType}'`);
}
}
@ -167,12 +152,7 @@ function validatePackageJson(json: JsonRecord): string[] {
expectFieldValue(json, "name", "ts-apt", add);
expectFieldValue(json, "license", "Apache-2.0", add);
expectFieldValue(json, "repository.type", "git", add);
expectFieldValue(
json,
"repository.url",
`https://github.com/${REPO_SLUG}.git`,
add,
);
expectFieldValue(json, "repository.url", `https://github.com/${REPO_SLUG}.git`, add);
return errors;
}

View file

@ -19,20 +19,11 @@
import fs from "node:fs";
import path from "node:path";
import {
fail,
logError,
logSuccess,
readJsonFile,
ROOT_DIR,
runMain,
usage,
} from "../devopslib.mts";
import { fail, logError, logSuccess, readJsonFile, ROOT_DIR, runMain, usage } from "../devopslib.mts";
const SHA_PIN_PATTERN = /#([0-9a-f]{40})$/;
/** Exact semver version, optionally prerelease; rejects ranges and tags. */
const EXACT_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/;
const EXACT_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/;
const NPM_REGISTRY_URL = "https://registry.npmjs.org/";
/** Parsed package.json, limited to the fields the check reads. */
@ -94,8 +85,7 @@ export function checkDependencyPin(
);
}
const spec =
packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name];
const spec = packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name];
if (spec === undefined) {
problems.push(`package.json has no ${name} dependency.`);
return problems;
@ -112,29 +102,21 @@ export function checkDependencyPin(
const root = packageLock.packages?.[""];
const rootSpec = root?.dependencies?.[name] ?? root?.devDependencies?.[name];
if (rootSpec !== spec) {
problems.push(
`package-lock.json root ${name} '${rootSpec}' does not match package.json '${spec}'.`,
);
problems.push(`package-lock.json root ${name} '${rootSpec}' does not match package.json '${spec}'.`);
}
const entry = packageLock.packages?.[`node_modules/${name}`];
if (entry === undefined) {
problems.push(`package-lock.json has no node_modules/${name} entry.`);
} else if (entry.link) {
problems.push(
`package-lock.json ${name} is a link to '${entry.resolved}'.`,
);
problems.push(`package-lock.json ${name} is a link to '${entry.resolved}'.`);
} else if (sha !== undefined) {
if (!entry.resolved?.endsWith(`#${sha}`)) {
problems.push(
`package-lock.json ${name} resolved '${entry.resolved}' does not match pin ${sha}.`,
);
problems.push(`package-lock.json ${name} resolved '${entry.resolved}' does not match pin ${sha}.`);
}
} else {
if (entry.version !== spec) {
problems.push(
`package-lock.json ${name} version '${entry.version}' does not match pin ${spec}.`,
);
problems.push(`package-lock.json ${name} version '${entry.version}' does not match pin ${spec}.`);
}
if (!entry.resolved?.startsWith(NPM_REGISTRY_URL)) {
problems.push(
@ -155,16 +137,10 @@ function main(): void {
fail(usage(import.meta.url, "<dependency> [<dependency>...]"));
}
const packageJson = readJsonFile(
path.join(ROOT_DIR, "package.json"),
) as PackageJson;
const packageLock = readJsonFile(
path.join(ROOT_DIR, "package-lock.json"),
) as PackageLock;
const packageJson = readJsonFile(path.join(ROOT_DIR, "package.json")) as PackageJson;
const packageLock = readJsonFile(path.join(ROOT_DIR, "package-lock.json")) as PackageLock;
const problems = names.flatMap((name) =>
checkDependencyPin(name, packageJson, packageLock),
);
const problems = names.flatMap((name) => checkDependencyPin(name, packageJson, packageLock));
for (const problem of problems) {
logError(problem);
}

View file

@ -5,11 +5,7 @@ import path from "node:path";
import process from "node:process";
import { defineCommand, defineOptions } from "@robingenz/zli";
import { z } from "zod";
import {
loadActionRepoData,
parseActionRepoSlug,
resolveActionRefSha,
} from "./opslib.mts";
import { loadActionRepoData, parseActionRepoSlug, resolveActionRefSha } from "./opslib.mts";
import {
COLORS,
createCliConfig,
@ -78,21 +74,13 @@ async function fetchJson(url: string): Promise<unknown | null> {
return response.json();
}
async function getLatestRelease(
repoSlug: string,
): Promise<GitHubRelease | null> {
const release = await fetchJson(
`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases/latest`,
);
return release && typeof release === "object"
? (release as GitHubRelease)
: null;
async function getLatestRelease(repoSlug: string): Promise<GitHubRelease | null> {
const release = await fetchJson(`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases/latest`);
return release && typeof release === "object" ? (release as GitHubRelease) : null;
}
async function getReleases(repoSlug: string): Promise<GitHubRelease[]> {
const releases = await fetchJson(
`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases?per_page=100`,
);
const releases = await fetchJson(`${GITHUB_API_BASE_URL}/repos/${repoSlug}/releases?per_page=100`);
return Array.isArray(releases) ? releases : [];
}
@ -101,11 +89,7 @@ function findReleaseBySha(
tagMap: Map<string, string>,
sha: string,
): GitHubRelease | null {
return (
releases.find(
(release) => tagMap.get(release.tag_name)?.toLowerCase() === sha,
) ?? null
);
return releases.find((release) => tagMap.get(release.tag_name)?.toLowerCase() === sha) ?? null;
}
function walkFiles(rootDir: string): string[] {
@ -142,11 +126,7 @@ function findActionPins(rootDir: string): ActionPin[] {
}
const spec = match[1] ?? "";
if (
!spec.includes("@") ||
spec.startsWith("./") ||
spec.startsWith("docker://")
) {
if (!spec.includes("@") || spec.startsWith("./") || spec.startsWith("docker://")) {
continue;
}
@ -179,10 +159,7 @@ async function getRepoData(repoSlug: string): Promise<RepoData> {
const promise = (async (): Promise<RepoData> => {
const { tagToSha: tagMap } = loadActionRepoData(repoSlug, process.cwd());
const [latestRelease, releases] = await Promise.all([
getLatestRelease(repoSlug),
getReleases(repoSlug),
]);
const [latestRelease, releases] = await Promise.all([getLatestRelease(repoSlug), getReleases(repoSlug)]);
return { tagMap, latestRelease, releases };
})();
@ -190,25 +167,15 @@ async function getRepoData(repoSlug: string): Promise<RepoData> {
return promise;
}
async function analyzeAction(
actionPath: string,
currentRef: string,
): Promise<ActionAnalysis> {
async function analyzeAction(actionPath: string, currentRef: string): Promise<ActionAnalysis> {
const repoSlug = parseActionRepoSlug(actionPath);
const { tagMap, latestRelease, releases } = await getRepoData(repoSlug);
const currentSha = resolveActionRefSha(
repoSlug,
currentRef,
tagMap,
process.cwd(),
);
const currentSha = resolveActionRefSha(repoSlug, currentRef, tagMap, process.cwd());
if (!currentSha) {
throw new Error(`Unable to resolve ref '${currentRef}' for ${repoSlug}.`);
}
const currentRelease = findReleaseBySha(releases, tagMap, currentSha);
const latestSha = latestRelease
? (tagMap.get(latestRelease.tag_name) ?? null)
: null;
const latestSha = latestRelease ? (tagMap.get(latestRelease.tag_name) ?? null) : null;
const isCurrent = latestSha !== null && latestSha === currentSha;
return {
@ -235,21 +202,10 @@ function printAnalysis(analysis: ActionAnalysis): number {
logFieldValue("Current ref", analysis.currentRef);
logFieldValue("Current sha", analysis.currentSha);
logFieldValue("Current release", analysis.currentRelease?.tag_name ?? "none");
logFieldValue(
"Current release URL",
analysis.currentRelease?.html_url ?? "none",
);
logFieldValue("Current release URL", analysis.currentRelease?.html_url ?? "none");
logFieldValue("Latest release", analysis.latestRelease?.tag_name ?? "none");
logFieldValue(
"Latest pin",
analysis.latestSha
? `${analysis.actionPath}@${analysis.latestSha}`
: "none",
);
logFieldValue(
"Latest release URL",
analysis.latestRelease?.html_url ?? "none",
);
logFieldValue("Latest pin", analysis.latestSha ? `${analysis.actionPath}@${analysis.latestSha}` : "none");
logFieldValue("Latest release URL", analysis.latestRelease?.html_url ?? "none");
if (analysis.latestSha === null) {
logError(`Status: no release found`);
return 1;
@ -266,10 +222,7 @@ const checkLatestActionPinCommand = defineCommand({
description: "Check whether a GitHub Actions reference is up to date",
options: defineOptions(
z.object({
scan: z
.boolean()
.default(false)
.describe("Scan a directory for pinned GitHub Actions references"),
scan: z.boolean().default(false).describe("Scan a directory for pinned GitHub Actions references"),
}),
{ s: "scan" },
),
@ -312,9 +265,7 @@ const checkLatestActionPinCommand = defineCommand({
const singleArg = args[0] ?? "";
const atIndex = singleArg.lastIndexOf("@");
if (atIndex <= 0 || atIndex === singleArg.length - 1) {
fail(
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
);
fail("Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.");
}
actionPath = singleArg.slice(0, atIndex);
@ -323,14 +274,10 @@ const checkLatestActionPinCommand = defineCommand({
actionPath = args[0] ?? "";
currentRef = args[1] ?? "";
if (actionPath.length === 0 || currentRef.length === 0) {
fail(
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
);
fail("Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.");
}
} else {
fail(
"Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.",
);
fail("Action reference must be provided as <owner/repo[/path]@ref> or <owner/repo[/path] ref>.");
}
const analysis = await analyzeAction(actionPath, currentRef);

View file

@ -1,11 +1,5 @@
#!/usr/bin/env -S node --experimental-strip-types
import {
fail,
logInfo,
logSuccess,
readJsonFile,
ROOT_DIR,
} from "../devopslib.mts";
import { fail, logInfo, logSuccess, readJsonFile, ROOT_DIR } from "../devopslib.mts";
const tag: string | undefined = process.env.RELEASE_TAG;
if (!tag) {
@ -14,17 +8,12 @@ if (!tag) {
}
if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(tag)) {
fail(
`Release tag '${tag}' is invalid. Expected semantic version format like v1.2.3 or v1.2.3-rc.1`,
);
fail(`Release tag '${tag}' is invalid. Expected semantic version format like v1.2.3 or v1.2.3-rc.1`);
}
const packageJsonPath = `${ROOT_DIR}/package.json`;
const packageJson = readJsonFile(packageJsonPath) as { version?: string };
if (
typeof packageJson.version !== "string" ||
packageJson.version.length === 0
) {
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
fail("package.json version is missing or invalid.");
}
@ -35,6 +24,4 @@ if (tag !== expectedTag) {
);
}
logSuccess(
`Release preflight passed for tag ${tag} and package version ${packageJson.version}.`,
);
logSuccess(`Release preflight passed for tag ${tag} and package version ${packageJson.version}.`);

View file

@ -48,9 +48,7 @@ function tryGit(
export function parseActionRepoSlug(actionPath: string): string {
const parts = actionPath.split("/").filter(Boolean);
if (parts.length < 2) {
throw new Error(
`Action path '${actionPath}' must include at least owner/repo.`,
);
throw new Error(`Action path '${actionPath}' must include at least owner/repo.`);
}
return `${parts[0]}/${parts[1]}`;
@ -86,10 +84,7 @@ export function formatSemVer(version: SemVer): string {
return `v${version.major}.${version.minor}.${version.patch}`;
}
export function loadActionRepoData(
repoSlug: string,
cwd = process.cwd(),
): ActionRepoData {
export function loadActionRepoData(repoSlug: string, cwd = process.cwd()): ActionRepoData {
const cached = actionRepoCache.get(repoSlug);
if (cached) {
return cached;

View file

@ -92,11 +92,7 @@ function extractActionUses(content: string): ActionUse[] {
}
const spec = match[1] ?? "";
if (
!spec.includes("@") ||
spec.startsWith("./") ||
spec.startsWith("docker://")
) {
if (!spec.includes("@") || spec.startsWith("./") || spec.startsWith("docker://")) {
continue;
}
@ -133,16 +129,9 @@ function findActionRefChanges(
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 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) {
@ -170,11 +159,7 @@ function findActionRefChanges(
}
if (currentUses.length > previousUses.length) {
for (
let index = previousUses.length;
index < currentUses.length;
index += 1
) {
for (let index = previousUses.length; index < currentUses.length; index += 1) {
const currentUse = currentUses[index];
if (!currentUse) {
continue;
@ -196,15 +181,7 @@ function findActionRefChanges(
function getChangedWorkflowYamlFiles(diffRange: string): string[] {
const files = runGitNameOnly(
[
"diff",
"--name-only",
"--diff-filter=ACMR",
diffRange,
"--",
".github/workflows",
".github/actions",
],
["diff", "--name-only", "--diff-filter=ACMR", diffRange, "--", ".github/workflows", ".github/actions"],
ROOT_DIR,
);
@ -223,21 +200,11 @@ function getFileContentAtRef(ref: string, filePath: string): string | null {
return result.stdout;
}
function validateModifiedActionRefs(
baseRef: string,
headRef: string,
): string[] {
function validateModifiedActionRefs(baseRef: string, headRef: string): string[] {
const errors: string[] = [];
const warnings: string[] = [];
const fetched = tryGit([
"fetch",
"--no-tags",
"--depth=200",
"origin",
baseRef,
headRef,
]);
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"}`,
@ -261,18 +228,14 @@ function validateModifiedActionRefs(
continue;
}
actionRefChanges.push(
...findActionRefChanges(filePath, previousContent, currentContent),
);
actionRefChanges.push(...findActionRefChanges(filePath, previousContent, currentContent));
}
if (actionRefChanges.length === 0) {
return errors;
}
logInfo(
`Validating ${actionRefChanges.length} modified GitHub Action reference(s) in PR changes.`,
);
logInfo(`Validating ${actionRefChanges.length} modified GitHub Action reference(s) in PR changes.`);
for (const change of actionRefChanges) {
if (!SHA_PIN_PATTERN.test(change.currentRef)) {
@ -288,22 +251,9 @@ function validateModifiedActionRefs(
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,
);
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(
@ -341,28 +291,19 @@ function validateModifiedActionRefs(
return errors;
}
const PROHIBITED_PATHS = [
"docs/api",
"docs/api-md",
];
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 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),
return [...new Set([...staged, ...unstaged, ...untracked])].sort((left, right) =>
left.localeCompare(right),
);
}
@ -374,21 +315,11 @@ 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 || "",
);
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,
);
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 };
@ -402,9 +333,7 @@ async function runChecks(options: PrCheckOptions): Promise<string[]> {
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(`Merge to 'main' is only allowed from 'staging'. Current source: '${headRef}'.`);
errors.push("To fix this, rebase your feature branch onto 'staging'.");
}
@ -425,33 +354,23 @@ async function runChecks(options: PrCheckOptions): Promise<string[]> {
return errors;
}
async function shouldAllowAdminBypass(
options: PrCheckOptions,
): Promise<boolean> {
async function shouldAllowAdminBypass(options: PrCheckOptions): Promise<boolean> {
if (!options.allowAdminBypass) {
return false;
}
return isAdminActor(
process.env.GITHUB_ACTOR ?? "",
process.env.GITHUB_REPOSITORY ?? "",
);
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)"),
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)",
),
.describe("Pull request head branch ref (for example: feature/my-feature)"),
allowAdminBypass: z
.boolean()
.default(false)
@ -476,9 +395,7 @@ const prChecksCommand = defineCommand({
logError(error);
}
if (allowAdminBypass) {
logWarn(
"Admin bypass is allowed for this pull request actor. Treating as warning only.",
);
logWarn("Admin bypass is allowed for this pull request actor. Treating as warning only.");
} else {
fail("Pull request checks failed.");
}

View file

@ -1,3 +1,3 @@
{
"extends": "../tsconfig.scripts.json"
}
}

View file

@ -1,8 +1,4 @@
import {
createPackageManager,
type CommandRunner,
type PackageName,
} from "ts-apt";
import { createPackageManager, type CommandRunner, type PackageName } from "ts-apt";
import { Cache, CacheKey } from "./cache.js";
import { Manifest, ManifestEntry } from "./manifest.js";
import { ActionPackageNames, buildFileList } from "./packages.js";
@ -34,11 +30,7 @@ export class ActionRunner {
private readonly commandRunner: CommandRunner;
private readonly logger: winston.Logger;
constructor(
cache: Cache,
commandRunner: CommandRunner,
logger: winston.Logger,
) {
constructor(cache: Cache, commandRunner: CommandRunner, logger: winston.Logger) {
this.cache = cache;
this.commandRunner = commandRunner;
this.logger = logger;
@ -56,17 +48,11 @@ export class ActionRunner {
* @param nativeArch Runner architecture as reported by dpkg (e.g. amd64).
* @returns Comma-delimited name[:arch]=version list.
*/
private toCsv(
packageNames: readonly PackageName[],
nativeArch: string,
): string {
private toCsv(packageNames: readonly PackageName[], nativeArch: string): string {
return [...packageNames]
.sort((a, b) => a.compareTo(b))
.map((name) => {
const foreignArch =
name.arch !== undefined && name.arch !== nativeArch
? `:${name.arch}`
: "";
const foreignArch = name.arch !== undefined && name.arch !== nativeArch ? `:${name.arch}` : "";
const version = name.version === undefined ? "" : `=${name.version}`;
return `${name.name}${foreignArch}${version}`;
})
@ -81,37 +67,21 @@ export class ActionRunner {
*/
async runAction(inputs: ActionInputs): Promise<ActionOutputs> {
const packageNames = ActionPackageNames.fromInput(inputs.packages);
const cacheKey = new CacheKey(
inputs.version,
FORCE_UPDATE_INCREMENT,
process.arch,
packageNames,
);
const cacheKey = new CacheKey(inputs.version, FORCE_UPDATE_INCREMENT, process.arch, packageNames);
let manifest = await this.cache.loadAndRestore(
cacheKey,
inputs.executeInstallScripts,
);
let manifest = await this.cache.loadAndRestore(cacheKey, inputs.executeInstallScripts);
const cacheHit = manifest !== undefined;
// Needed on a cache hit too, to resolve the native arch for outputs.
// apt-fast only speeds up installs, so skip probing for it on a hit.
const installManager = await createPackageManager(
!cacheHit,
this.logger,
this.logger,
);
const installManager = await createPackageManager(!cacheHit, this.logger, this.logger);
if (!cacheHit) {
const packageInfos = await installManager.install(packageNames.toArray());
// Collect each package's installed files so the archives have content.
const entries = await Promise.all(
packageInfos.map(
async (info) =>
new ManifestEntry(
info.name,
await buildFileList(info.name, installManager),
),
async (info) => new ManifestEntry(info.name, await buildFileList(info.name, installManager)),
),
);
manifest = new Manifest(new Date(), entries, cacheKey);

View file

@ -17,9 +17,7 @@ export const CACHE_KEY_FILENAME = "cache_key.md5";
* @param cacheDir Directory name under the user's home directory.
* @returns Absolute path to the cache directory.
*/
export function resolveCacheDir(
cacheDir: string = CACHE_DEFAULT_DIRNAME,
): string {
export function resolveCacheDir(cacheDir: string = CACHE_DEFAULT_DIRNAME): string {
return path.join(os.homedir(), cacheDir);
}
@ -44,10 +42,7 @@ const EXTRACT_ARCHIVES_SCRIPT =
type InstallScriptType = "preinst" | "postinst";
/** Order dpkg runs maintainer scripts in during install. */
const INSTALL_SCRIPT_ORDER: readonly InstallScriptType[] = [
"preinst",
"postinst",
];
const INSTALL_SCRIPT_ORDER: readonly InstallScriptType[] = ["preinst", "postinst"];
/** Action argument dpkg passes to each maintainer script on a fresh install. */
const INSTALL_SCRIPT_ACTIONS: Readonly<Record<InstallScriptType, string>> = {
@ -73,20 +68,12 @@ export class CacheKey {
/** MD5 hash of the serialized cache key. Not considered sensitive in the GitHub Cache action. */
readonly hash: string;
constructor(
version: string,
forceUpdateIncrement: string,
arch: string,
packageNames: ActionPackageNames,
) {
constructor(version: string, forceUpdateIncrement: string, arch: string, packageNames: ActionPackageNames) {
this.version = version;
this.forceUpdateIncrement = forceUpdateIncrement;
this.arch = arch;
this.packageNames = packageNames;
this.hash = crypto
.createHash("md5")
.update(this.toJSON(false))
.digest("hex");
this.hash = crypto.createHash("md5").update(this.toJSON(false)).digest("hex");
}
/**
@ -171,38 +158,26 @@ export class Cache {
* @param packageName Package whose script to run.
* @param scriptType Maintainer script to run.
*/
private async runInstallScript(
packageName: PackageName,
scriptType: InstallScriptType,
): Promise<void> {
private async runInstallScript(packageName: PackageName, scriptType: InstallScriptType): Promise<void> {
const scriptPath = await findInstallScript(packageName, scriptType, "/");
if (!scriptPath) {
this.logger.info(
`No ${scriptType} script found for package ${packageName.serialize()}, skipping`,
);
this.logger.info(`No ${scriptType} script found for package ${packageName.serialize()}, skipping`);
return;
}
this.logger.info(
`Running ${scriptType} script '${path.basename(scriptPath)}' for package ${packageName.serialize()}...`,
);
await this.commandRunner.run(
"sudo",
["sh", "-x", scriptPath, INSTALL_SCRIPT_ACTIONS[scriptType]],
{ timeoutMs: NO_TIMEOUT_MS },
);
await this.commandRunner.run("sudo", ["sh", "-x", scriptPath, INSTALL_SCRIPT_ACTIONS[scriptType]], {
timeoutMs: NO_TIMEOUT_MS,
});
this.logger.info(`Ran ${scriptType} script for ${packageName.name}`);
}
async loadAndRestore(
key: CacheKey,
executeInstallScripts: boolean,
): Promise<Manifest | undefined> {
async loadAndRestore(key: CacheKey, executeInstallScripts: boolean): Promise<Manifest | undefined> {
const cacheHit = await ghcache.restoreCache([this.path], key.hash);
if (!cacheHit) {
this.logger.info(
`Cache miss for key ${key.hash}, skipping cache restore.`,
);
this.logger.info(`Cache miss for key ${key.hash}, skipping cache restore.`);
return undefined;
}
@ -210,9 +185,7 @@ export class Cache {
const contents = await fs
.readdir(this.path)
.then((entries) => entries.join("\n"))
.catch(
(reason: any) => `Unable to read cache directory contents: ${reason}`,
);
.catch((reason: any) => `Unable to read cache directory contents: ${reason}`);
this.logger.error(
`${prefixMessage}, skipping cache restore.\n` +
`This may indicate a cache corruption or an unexpected cache hit for a different package set.\n` +
@ -232,13 +205,9 @@ export class Cache {
return undefined;
}
const manifestAllPath = path.join(this.path, MANIFEST_ALL_FILENAME);
const manifestAll = await Manifest.readFromFile(manifestAllPath).catch(
() => undefined,
);
const manifestAll = await Manifest.readFromFile(manifestAllPath).catch(() => undefined);
if (!manifestAll || manifestAll.entries.length === 0) {
await logFileError(
`Manifest of all packages not found or empty at ${manifestAllPath}`,
);
await logFileError(`Manifest of all packages not found or empty at ${manifestAllPath}`);
return undefined;
}
@ -250,10 +219,7 @@ export class Cache {
.filter((entry) => entry.filepaths.length > 0)
.map((entry) => ({
packageName: entry.packageName,
archivePath: path.join(
this.path,
entry.packageName.serialize() + ".tar",
),
archivePath: path.join(this.path, entry.packageName.serialize() + ".tar"),
}));
for (const { archivePath } of archivePaths) {
if (
@ -291,24 +257,17 @@ export class Cache {
*
* @param archivePaths Absolute paths to the package archives.
*/
private async extractArchives(
archivePaths: readonly string[],
): Promise<void> {
private async extractArchives(archivePaths: readonly string[]): Promise<void> {
if (archivePaths.length === 0) {
return;
}
await this.commandRunner.run(
"sudo",
["sh", "-c", EXTRACT_ARCHIVES_SCRIPT, "sh", ...archivePaths],
{ timeoutMs: NO_TIMEOUT_MS },
);
await this.commandRunner.run("sudo", ["sh", "-c", EXTRACT_ARCHIVES_SCRIPT, "sh", ...archivePaths], {
timeoutMs: NO_TIMEOUT_MS,
});
}
private async createArchive(manifestEntry: ManifestEntry): Promise<void> {
const archivePath = path.join(
this.path,
manifestEntry.packageName.serialize() + ".tar",
);
const archivePath = path.join(this.path, manifestEntry.packageName.serialize() + ".tar");
if (
await fs
.access(archivePath)
@ -348,11 +307,7 @@ export class Cache {
{ input: manifestEntry.filepaths.join("\0"), timeoutMs: NO_TIMEOUT_MS },
);
// Hand the archive back to the runner user so the cache save can manage it.
await this.commandRunner.run("sudo", [
"chown",
`${os.userInfo().uid}:${os.userInfo().gid}`,
archivePath,
]);
await this.commandRunner.run("sudo", ["chown", `${os.userInfo().uid}:${os.userInfo().gid}`, archivePath]);
this.logger.info(
`Archive created for package ${manifestEntry.packageName.serialize()} at ${archivePath}`,
);
@ -362,9 +317,7 @@ export class Cache {
const allEntries = manifest.entries;
const packages = manifest.cacheKey.packageNames;
// Keep the installed name, which carries the resolved version and arch.
const mainEntries = allEntries.filter((entry) =>
packages.includes(entry.packageName),
);
const mainEntries = allEntries.filter((entry) => packages.includes(entry.packageName));
const write = async (manifest: Manifest, filename: string) => {
this.logger.info(
@ -378,14 +331,8 @@ export class Cache {
const now = new Date();
await write(
new Manifest(now, mainEntries, manifest.cacheKey),
MANIFEST_MAIN_FILENAME,
);
await write(
new Manifest(now, allEntries, manifest.cacheKey),
MANIFEST_ALL_FILENAME,
);
await write(new Manifest(now, mainEntries, manifest.cacheKey), MANIFEST_MAIN_FILENAME);
await write(new Manifest(now, allEntries, manifest.cacheKey), MANIFEST_ALL_FILENAME);
}
/**
@ -397,20 +344,12 @@ export class Cache {
async archiveAndSave(manifest: Manifest): Promise<number> {
const key = manifest.cacheKey;
if (!ghcache.isFeatureAvailable()) {
throw new Error(
"GitHub Actions cache service is not available in this environment",
);
throw new Error("GitHub Actions cache service is not available in this environment");
}
await fs.mkdir(this.path, { recursive: true });
this.logger.info(
`Writing cache key to ${path.join(this.path, CACHE_KEY_FILENAME)} with ${key}...`,
);
await fs.writeFile(
path.join(this.path, CACHE_KEY_FILENAME),
key.hash,
"utf8",
);
this.logger.info(`Writing cache key to ${path.join(this.path, CACHE_KEY_FILENAME)} with ${key}...`);
await fs.writeFile(path.join(this.path, CACHE_KEY_FILENAME), key.hash, "utf8");
this.logger.info(`Wrote cache key`);
await this.writeManifests(manifest);
@ -418,15 +357,10 @@ export class Cache {
await this.createArchive(entry);
}
this.logger.info(
`Saving cache with key ${key} for ${manifest.entries.length} entries...`,
);
this.logger.info(`Saving cache with key ${key} for ${manifest.entries.length} entries...`);
try {
const cacheId = await ghcache.saveCache(
[this.path],
manifest.cacheKey.hash,
);
const cacheId = await ghcache.saveCache([this.path], manifest.cacheKey.hash);
if (cacheId === undefined) {
throw new Error("Cache save failed: no cache action ID returned.");
}

View file

@ -18,13 +18,7 @@ import { Instruments } from "./instrumentation.js";
function ensureBundledScriptsExecutable(
bundleDir: string = path.dirname(fileURLToPath(import.meta.url)),
): void {
const aptFastPath = path.join(
bundleDir,
"..",
"scripts",
"dist",
"apt-fast.sh",
);
const aptFastPath = path.join(bundleDir, "..", "scripts", "dist", "apt-fast.sh");
if (fs.existsSync(aptFastPath)) {
fs.chmodSync(aptFastPath, 0o755);
}
@ -37,16 +31,12 @@ function ensureBundledScriptsExecutable(
* @returns Parsed behavior enum value.
* @throws Error when value is outside the supported set.
*/
function parseEmptyPackagesBehavior(
value: string,
): "error" | "warn" | "ignore" {
function parseEmptyPackagesBehavior(value: string): "error" | "warn" | "ignore" {
if (value === "error" || value === "warn" || value === "ignore") {
return value;
}
throw new Error(
`empty_packages_behavior value '${value}' must be one of: error, warn, ignore.`,
);
throw new Error(`empty_packages_behavior value '${value}' must be one of: error, warn, ignore.`);
}
/**
@ -55,8 +45,7 @@ function parseEmptyPackagesBehavior(
* @returns Parsed and validated action inputs.
*/
function getInputs(): ActionInputs {
const emptyPackagesBehaviorRaw =
core.getInput("empty_packages_behavior") || "error";
const emptyPackagesBehaviorRaw = core.getInput("empty_packages_behavior") || "error";
return {
packages: core.getInput("packages", { required: true }),
@ -80,10 +69,7 @@ async function main(): Promise<void> {
let failed = false;
try {
ensureBundledScriptsExecutable();
const commandRunner = new DefaultCommandRunner(
instruments.execLogger,
instruments.execLogger,
);
const commandRunner = new DefaultCommandRunner(instruments.execLogger, instruments.execLogger);
const outputs = await runAction(
inputs,

View file

@ -14,9 +14,7 @@ import {
const APP_LOG_FILENAME = "capa_app.log";
const EXEC_LOG_FILENAME = "capa_exec.log";
function createStream(
filepath: string,
): winston.transports.StreamTransportInstance {
function createStream(filepath: string): winston.transports.StreamTransportInstance {
return new winston.transports.Stream({
stream: fs.createWriteStream(filepath, {
flags: "a",
@ -36,8 +34,7 @@ export class Artifacts {
/** Whether debug is enabled which affects artifacts otherwise not. */
readonly debug: boolean = false,
/** Unique runner ID of the GitHub Action workflow run. */
readonly runId: string = process.env.GITHUB_RUN_ID ??
`ghrunid-notfound-${crypto.randomUUID()}`,
readonly runId: string = process.env.GITHUB_RUN_ID ?? `ghrunid-notfound-${crypto.randomUUID()}`,
) {}
/** Upload artifacts to GitHub Actions via [@actions/artifact]. */
@ -145,13 +142,7 @@ export class Instruments {
MANIFEST_MAIN_FILENAME,
MANIFEST_ALL_FILENAME,
]);
this.appLogger = createGitHubLogger(
debug,
path.join(this.cacheDirPath, APP_LOG_FILENAME),
);
this.execLogger = createExecLogger(
debug,
path.join(this.cacheDirPath, EXEC_LOG_FILENAME),
);
this.appLogger = createGitHubLogger(debug, path.join(this.cacheDirPath, APP_LOG_FILENAME));
this.execLogger = createExecLogger(debug, path.join(this.cacheDirPath, EXEC_LOG_FILENAME));
}
}

View file

@ -29,10 +29,7 @@ export class ManifestEntry {
* @returns The deserialized entry.
*/
static fromJSON(json: any): ManifestEntry {
return new ManifestEntry(
packageNameFromJSON(json.packageName),
json.filepaths as string[],
);
return new ManifestEntry(packageNameFromJSON(json.packageName), json.filepaths as string[]);
}
}
@ -69,11 +66,7 @@ export class Manifest {
return new Manifest(
new Date(json.created),
json.entries.map(
(e: any) =>
new ManifestEntry(
packageNameFromJSON(e.packageName),
e.filepaths as string[],
),
(e: any) => new ManifestEntry(packageNameFromJSON(e.packageName), e.filepaths as string[]),
),
CacheKey.fromJSON(json.cacheKey),
);
@ -97,10 +90,6 @@ export class Manifest {
* @param filePath Destination path.
*/
async writeToFile(filePath: string): Promise<void> {
await fs.promises.writeFile(
filePath,
JSON.stringify(this, null, 2),
"utf8",
);
await fs.promises.writeFile(filePath, JSON.stringify(this, null, 2), "utf8");
}
}

View file

@ -1,11 +1,6 @@
import * as fs from "fs";
import path from "path";
import {
createPackageName,
deserializePackageName,
type PackageName,
type PackageManager,
} from "ts-apt";
import { createPackageName, deserializePackageName, type PackageName, type PackageManager } from "ts-apt";
import winston from "winston";
/**
@ -21,8 +16,7 @@ export async function buildFileList(
root: string = "/",
): Promise<string[]> {
// Converts absolute paths to tar-relative paths.
const tarRelativePath = (filePath: string) =>
filePath.startsWith("/") ? filePath.slice(1) : filePath;
const tarRelativePath = (filePath: string) => (filePath.startsWith("/") ? filePath.slice(1) : filePath);
const files = (await packageManager.listInstalledFiles(packageName))
.filter((filePath) => {
@ -102,7 +96,7 @@ export class ActionPackageNames {
const names: PackageName[] = [];
for (let index = 0; index < tokens.length; ) {
for (let index = 0; index < tokens.length;) {
const current = tokens[index]!;
const next = tokens[index + 1];
@ -123,23 +117,12 @@ export class ActionPackageNames {
const split = splitNameArch(namePart.trim());
let arch = split.arch;
if (
arch === undefined &&
next !== undefined &&
!next.includes("=") &&
next.trim() !== ""
) {
if (arch === undefined && next !== undefined && !next.includes("=") && next.trim() !== "") {
arch = next.trim();
index += 1;
}
names.push(
createPackageName(
split.name,
version === "" ? undefined : version,
arch,
),
);
names.push(createPackageName(split.name, version === "" ? undefined : version, arch));
index += 1;
continue;
}
@ -157,15 +140,9 @@ export class ActionPackageNames {
}
static fromJSON(json: any): ActionPackageNames {
const sourceItems = Array.isArray(json)
? json
: Array.isArray(json?.items)
? json.items
: [];
const sourceItems = Array.isArray(json) ? json : Array.isArray(json?.items) ? json.items : [];
const items = sourceItems.map((item: any) =>
createPackageName(item.name, item.version, item.arch),
);
const items = sourceItems.map((item: any) => createPackageName(item.name, item.version, item.arch));
return new ActionPackageNames(items);
}
@ -190,9 +167,7 @@ export class ActionPackageNames {
return this.items.some(
(requested) =>
requested.name === installed.name &&
(requested.arch === undefined ||
installed.arch === undefined ||
requested.arch === installed.arch),
(requested.arch === undefined || installed.arch === undefined || requested.arch === installed.arch),
);
}
}
@ -210,10 +185,7 @@ export async function updateAptLists(
): Promise<void> {
const maxDepth = 5;
const search = async (
currentPath: string,
currentDepth: number,
): Promise<boolean> => {
const search = async (currentPath: string, currentDepth: number): Promise<boolean> => {
if (currentDepth > maxDepth) {
return false;
}
@ -239,14 +211,10 @@ export async function updateAptLists(
};
if (await search(aptListsPath, 0)) {
logger.info(
`Apt lists directory '${aptListsPath}' appears to contain files, skipping 'apt update'`,
);
logger.info(`Apt lists directory '${aptListsPath}' appears to contain files, skipping 'apt update'`);
return;
}
logger.info(
`Apt lists directory '${aptListsPath}' appears stale or empty, running 'apt update'...`,
);
logger.info(`Apt lists directory '${aptListsPath}' appears stale or empty, running 'apt update'...`);
await packageManager.update();
}

View file

@ -67,9 +67,7 @@ describe("ActionRunner", () => {
const { runner, cache } = createRunner();
const packageNames = ActionPackageNames.fromInput("curl");
const cacheKey = new CacheKey("v1", "0", process.arch, packageNames);
cache.loadAndRestore.mockResolvedValue(
new Manifest(new Date(), [], cacheKey),
);
cache.loadAndRestore.mockResolvedValue(new Manifest(new Date(), [], cacheKey));
vi.mocked(createPackageManager).mockResolvedValue({
getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH),
} as never);
@ -108,8 +106,6 @@ describe("ActionRunner", () => {
expect(cache.archiveAndSave).toHaveBeenCalledTimes(1);
const [manifest] = cache.archiveAndSave.mock.calls[0]!;
expect(manifest.entries).toEqual([
new ManifestEntry(installedPackage, [installedFile.slice(1)]),
]);
expect(manifest.entries).toEqual([new ManifestEntry(installedPackage, [installedFile.slice(1)])]);
});
});

View file

@ -1,9 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
runAction,
type ActionInputs,
type ActionOutputs,
} from "../src/action.js";
import { runAction, type ActionInputs, type ActionOutputs } from "../src/action.js";
import { Manifest, ManifestEntry } from "../src/manifest.js";
import { CacheKey } from "../src/cache.js";
import { createPackageName } from "ts-apt";
@ -38,12 +34,7 @@ describe("runAction", () => {
};
const packageNames = ActionPackageNames.fromInput(inputs.packages);
const cacheKey = new CacheKey(
inputs.version,
"0",
process.arch,
packageNames,
);
const cacheKey = new CacheKey(inputs.version, "0", process.arch, packageNames);
const manifest = new Manifest(
new Date(),
[
@ -111,16 +102,9 @@ describe("runAction", () => {
);
const mockCache = { loadAndRestore: async () => manifest };
const outputs = await runAction(
inputs,
{} as never,
mockCache as never,
{} as never,
);
const outputs = await runAction(inputs, {} as never, mockCache as never, {} as never);
expect(outputs.packageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER}`,
);
expect(outputs.packageVersionList).toBe(`${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER}`);
expect(outputs.allPackageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
);

View file

@ -1,11 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import * as ghcache from "@actions/cache";
import {
createPackageName,
type CommandOptions,
type PackageName,
} from "ts-apt";
import { createPackageName, type CommandOptions, type PackageName } from "ts-apt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import winston from "winston";
@ -25,9 +21,7 @@ const { HOME_DIR } = await vi.hoisted(async () => {
const nodeOs = await import("node:os");
const nodePath = await import("node:path");
return {
HOME_DIR: nodeFs.mkdtempSync(
nodePath.join(nodeOs.tmpdir(), "cache-restore-test-"),
),
HOME_DIR: nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), "cache-restore-test-")),
};
});
@ -52,20 +46,11 @@ const INSTALL_ORDER = [
];
// Packages without files (e.g. metapackages) get no archive.
const METAPACKAGE = createPackageName("meta", "1.0");
const KEY = new CacheKey(
"v1",
"1",
"x86_64",
ActionPackageNames.fromInput(REQUESTED_PACKAGE.serialize()),
);
const KEY = new CacheKey("v1", "1", "x86_64", ActionPackageNames.fromInput(REQUESTED_PACKAGE.serialize()));
const archivePath = (packageName: PackageName) =>
path.join(CACHE_DIR, packageName.serialize() + ".tar");
const filesOf = (packageName: PackageName) => [
`usr/lib/${packageName.name}.so`,
];
const entryOf = (packageName: PackageName) =>
new ManifestEntry(packageName, filesOf(packageName));
const archivePath = (packageName: PackageName) => path.join(CACHE_DIR, packageName.serialize() + ".tar");
const filesOf = (packageName: PackageName) => [`usr/lib/${packageName.name}.so`];
const entryOf = (packageName: PackageName) => new ManifestEntry(packageName, filesOf(packageName));
const MANIFEST = new Manifest(
new Date(0),
[...INSTALL_ORDER.map(entryOf), new ManifestEntry(METAPACKAGE)],
@ -74,11 +59,9 @@ const MANIFEST = new Manifest(
async function writeCache(archivedPackages = INSTALL_ORDER): Promise<void> {
fs.mkdirSync(CACHE_DIR, { recursive: true });
await new Manifest(
MANIFEST.created,
[entryOf(REQUESTED_PACKAGE)],
KEY,
).writeToFile(path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME));
await new Manifest(MANIFEST.created, [entryOf(REQUESTED_PACKAGE)], KEY).writeToFile(
path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME),
);
await MANIFEST.writeToFile(path.join(CACHE_DIR, MANIFEST_ALL_FILENAME));
archivedPackages.forEach((pkg) => fs.writeFileSync(archivePath(pkg), ""));
}
@ -96,20 +79,18 @@ const argValue = (args: string[], flag: string) =>
/** Records sudo tar calls, including the file list piped to tar create. */
function recordTar(calls: TarCalls) {
return vi.fn(
async (_command: string, args: string[] = [], options: CommandOptions = {}) => {
const archive = argValue(args, "--file") ?? "";
if (args.includes("--create")) {
calls.created.push([archive, String(options.input ?? "").split("\0")]);
}
// Extraction runs as `sh -c <script> sh <archive>...`.
if (args[0] === "sh" && args[1] === "-c") {
calls.extracted.push(...args.slice(4));
calls.extractCommands = (calls.extractCommands ?? 0) + 1;
}
return { cmdLine: "", stdout: "", stderr: "", exitCode: 0 };
},
);
return vi.fn(async (_command: string, args: string[] = [], options: CommandOptions = {}) => {
const archive = argValue(args, "--file") ?? "";
if (args.includes("--create")) {
calls.created.push([archive, String(options.input ?? "").split("\0")]);
}
// Extraction runs as `sh -c <script> sh <archive>...`.
if (args[0] === "sh" && args[1] === "-c") {
calls.extracted.push(...args.slice(4));
calls.extractCommands = (calls.extractCommands ?? 0) + 1;
}
return { cmdLine: "", stdout: "", stderr: "", exitCode: 0 };
});
}
function createCache(calls: TarCalls = { created: [], extracted: [] }): Cache {
@ -178,17 +159,13 @@ describe("Cache", () => {
const cacheId = await createCache(calls).archiveAndSave(MANIFEST);
expect(cacheId).toBe(CACHE_ID);
expect(calls.created).toEqual(
INSTALL_ORDER.map((pkg) => [archivePath(pkg), filesOf(pkg)]),
);
expect(calls.created).toEqual(INSTALL_ORDER.map((pkg) => [archivePath(pkg), filesOf(pkg)]));
});
it("archiveAndSave manifest written records package files", async () => {
await createCache().archiveAndSave(MANIFEST);
const written = await Manifest.readFromFile(
path.join(CACHE_DIR, MANIFEST_ALL_FILENAME),
);
const written = await Manifest.readFromFile(path.join(CACHE_DIR, MANIFEST_ALL_FILENAME));
expect(written.entries.map((entry) => entry.filepaths)).toEqual(
MANIFEST.entries.map((entry) => entry.filepaths),
);
@ -199,9 +176,9 @@ describe("Cache", () => {
const manifest = await createCache().loadAndRestore(KEY, false);
expect(
manifest?.entries.map((entry) => entry.packageName.serialize()),
).toEqual(MANIFEST.entries.map((entry) => entry.packageName.serialize()));
expect(manifest?.entries.map((entry) => entry.packageName.serialize())).toEqual(
MANIFEST.entries.map((entry) => entry.packageName.serialize()),
);
});
it("archiveAndSave requested package without version records installed entry in main manifest", async () => {
@ -212,18 +189,11 @@ describe("Cache", () => {
ActionPackageNames.fromInput(REQUESTED_PACKAGE.name),
);
await createCache().archiveAndSave(
new Manifest(MANIFEST.created, MANIFEST.entries, unversionedKey),
);
await createCache().archiveAndSave(new Manifest(MANIFEST.created, MANIFEST.entries, unversionedKey));
const written = await Manifest.readFromFile(
path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME),
);
expect(
written.entries.map((entry) => [
entry.packageName.serialize(),
entry.filepaths,
]),
).toEqual([[REQUESTED_PACKAGE.serialize(), filesOf(REQUESTED_PACKAGE)]]);
const written = await Manifest.readFromFile(path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME));
expect(written.entries.map((entry) => [entry.packageName.serialize(), entry.filepaths])).toEqual([
[REQUESTED_PACKAGE.serialize(), filesOf(REQUESTED_PACKAGE)],
]);
});
});

View file

@ -2,11 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
checkDependencyPin,
type PackageJson,
type PackageLock,
} from "../scripts/ops/check_deps_pin.mts";
import { checkDependencyPin, type PackageJson, type PackageLock } from "../scripts/ops/check_deps_pin.mts";
const NAME = "pinned-dep";
const VERSION = "1.2.3";
@ -21,10 +17,7 @@ const packageJsonOf = (spec: string): PackageJson => ({
dependencies: { [NAME]: spec },
});
const lockOf = (
rootSpec: string,
entry: NonNullable<PackageLock["packages"]>[string],
): PackageLock => ({
const lockOf = (rootSpec: string, entry: NonNullable<PackageLock["packages"]>[string]): PackageLock => ({
packages: {
"": { dependencies: { [NAME]: rootSpec } },
[`node_modules/${NAME}`]: entry,
@ -54,27 +47,19 @@ describe("checkDependencyPin", () => {
it.each(["^1.2.3", "~1.2.3", "latest", "1.2", "git+https://x.git#main"])(
"checkDependencyPin unpinned spec %s returns a pin problem",
(spec) => {
expect(check(spec, lockOf(spec, REGISTRY_ENTRY))).toEqual([
expect.stringContaining("is not pinned"),
]);
expect(check(spec, lockOf(spec, REGISTRY_ENTRY))).toEqual([expect.stringContaining("is not pinned")]);
},
);
it.each([
["version", { ...REGISTRY_ENTRY, version: "1.2.4" }, "version"],
[
"resolved",
{ ...REGISTRY_ENTRY, resolved: "https://evil.example/x.tgz" },
"is not from",
],
["resolved", { ...REGISTRY_ENTRY, resolved: "https://evil.example/x.tgz" }, "is not from"],
["integrity", { ...REGISTRY_ENTRY, integrity: undefined }, "integrity"],
["link", { ...REGISTRY_ENTRY, link: true }, "is a link"],
])(
"checkDependencyPin registry pin with mismatched lock %s returns a problem",
(_field, entry, message) => {
expect(check(VERSION, lockOf(VERSION, entry))).toEqual([
expect.stringContaining(message),
]);
expect(check(VERSION, lockOf(VERSION, entry))).toEqual([expect.stringContaining(message)]);
},
);

View file

@ -50,9 +50,7 @@ describe("instrumentation", () => {
const artifacts = new Artifacts("/dir", ARTIFACT_FILENAMES, false, "123");
expect(artifacts.name).toBe(
"capa_artifacts-123-2-list_versions-execute",
);
expect(artifacts.name).toBe("capa_artifacts-123-2-list_versions-execute");
});
});
@ -71,9 +69,7 @@ describe("instrumentation", () => {
expect(instruments.cacheDirPath).toBe(expectedDir);
expect(instruments.artifacts.dir).toBe(expectedDir);
await vi.waitFor(() =>
expect(
fs.readFileSync(path.join(expectedDir, "capa_app.log"), "utf8"),
).toContain("app message"),
expect(fs.readFileSync(path.join(expectedDir, "capa_app.log"), "utf8")).toContain("app message"),
);
fs.rmSync(home, { recursive: true, force: true });
});

View file

@ -9,20 +9,10 @@ import { ActionPackageNames } from "../src/packages.js";
describe("manifest", () => {
it("serializes and deserializes manifest entries", () => {
const cacheKey = new CacheKey(
"v1",
"4",
"amd64",
ActionPackageNames.fromInput("curl git"),
);
const cacheKey = new CacheKey("v1", "4", "amd64", ActionPackageNames.fromInput("curl git"));
const manifest = new Manifest(
new Date("2026-01-01T00:00:00.000Z"),
[
new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), [
"/b",
"/a",
]),
],
[new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), ["/b", "/a"])],
cacheKey,
);
@ -35,19 +25,10 @@ describe("manifest", () => {
it("writes and reads manifest files", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-test-"));
const filePath = path.join(tempDir, "manifest.json");
const cacheKey = new CacheKey(
"v1",
"4",
"amd64",
ActionPackageNames.fromInput("curl"),
);
const cacheKey = new CacheKey("v1", "4", "amd64", ActionPackageNames.fromInput("curl"));
const manifest = new Manifest(
new Date("2026-01-01T00:00:00.000Z"),
[
new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), [
"usr/bin/curl",
]),
],
[new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), ["usr/bin/curl"])],
cacheKey,
);

View file

@ -2,12 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createPackageName } from "ts-apt";
import {
ActionPackageNames,
updateAptLists,
buildFileList,
findInstallScript,
} from "../src/packages.js";
import { ActionPackageNames, updateAptLists, buildFileList, findInstallScript } from "../src/packages.js";
import { describe, expect, it, vi } from "vitest";
const PACKAGE_NAME = createPackageName("test-package", "1.0.0", "amd64");
@ -58,22 +53,14 @@ describe("packages", () => {
it("findInstallScript returns undefined when scripts directory does not exist", async () => {
const missingRoot = makeTempRoot("pkg-missing-");
const result = await findInstallScript(
PACKAGE_NAME,
"preinst",
missingRoot,
);
const result = await findInstallScript(PACKAGE_NAME, "preinst", missingRoot);
expect(result).toBeUndefined();
});
it("findInstallScript returns undefined when no matching scripts are found", async () => {
const root = makeTempRoot("pkg-no-match-");
const scriptsDir = ensureScriptsDir(root);
fs.writeFileSync(
path.join(scriptsDir, "other-package.preinst"),
"",
"utf8",
);
fs.writeFileSync(path.join(scriptsDir, "other-package.preinst"), "", "utf8");
const result = await findInstallScript(PACKAGE_NAME, "preinst", root);
expect(result).toBeUndefined();
@ -82,17 +69,9 @@ describe("packages", () => {
it("findInstallScript returns the first matching script when multiple are found", async () => {
const root = makeTempRoot("pkg-match-");
const scriptsDir = ensureScriptsDir(root);
fs.writeFileSync(
path.join(scriptsDir, "test-package.postinst"),
"",
"utf8",
);
fs.writeFileSync(path.join(scriptsDir, "test-package.postinst"), "", "utf8");
fs.writeFileSync(path.join(scriptsDir, "test-package.preinst"), "", "utf8");
fs.writeFileSync(
path.join(scriptsDir, "test-package.preinst.backup"),
"",
"utf8",
);
fs.writeFileSync(path.join(scriptsDir, "test-package.preinst.backup"), "", "utf8");
const result = await findInstallScript(PACKAGE_NAME, "preinst", root);
expect(result).toBe(path.join(scriptsDir, "test-package.preinst"));
@ -108,11 +87,7 @@ describe("packages", () => {
const scriptsDir = ensureScriptsDir(root);
fs.writeFileSync(path.join(scriptsDir, scriptFilename), "", "utf8");
const result = await findInstallScript(
createPackageName(name),
"postinst",
root,
);
const result = await findInstallScript(createPackageName(name), "postinst", root);
expect(result).toBe(path.join(scriptsDir, scriptFilename));
},
);
@ -122,11 +97,7 @@ describe("packages", () => {
const scriptsDir = ensureScriptsDir(root);
fs.writeFileSync(path.join(scriptsDir, "libaXb.postinst"), "", "utf8");
const result = await findInstallScript(
createPackageName("liba.b"),
"postinst",
root,
);
const result = await findInstallScript(createPackageName("liba.b"), "postinst", root);
expect(result).toBeUndefined();
});
@ -142,11 +113,7 @@ describe("packages", () => {
update: mockUpdate,
};
await updateAptLists(
packageManager as any,
mockLogger as any,
aptListsPath,
);
await updateAptLists(packageManager as any, mockLogger as any, aptListsPath);
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
@ -211,9 +178,7 @@ describe("packages", () => {
["different arch returns false", "xdot:i386", createPackageName("xdot", "1.3-1", "amd64"), false],
["different name returns false", "xdot", createPackageName("rolldice", "1.16"), false],
])("includes %s", (_case, input, installed, expected) => {
expect(ActionPackageNames.fromInput(input).includes(installed)).toBe(
expected,
);
expect(ActionPackageNames.fromInput(input).includes(installed)).toBe(expected);
});
});
});