mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-11-13 21:36:56 +00:00
style: set Prettier print width to 110 and reformat (#246)
* style: set Prettier print width to 110 and reformat Without a repo config, the Prettier CLI and Trunk used the default width of 80, while editors with a user printWidth reflowed files on save, producing formatting-only diffs. Pin the width in .prettierrc.json and reformat source, tests, scripts and JS/TS config files to match. Files that already failed Prettier at width 80 for unrelated reasons (tsconfig*.json, .trunk/*, V2_FAQ.MD) are left untouched. No code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover cache restore and save failure paths Codecov flagged lines in #246 as uncovered patch lines; they were untested before the reformat touched them. Add behavior tests for: - Cache: cache miss, missing or empty manifests, unreadable cache dir, metapackage-only restore, install scripts running after their archive with dpkg actions, existing archives, unavailable cache service, and save failures. - ManifestEntry.fromJSON round trip. - buildFileList dropping missing files, updateAptLists skipping update for populated lists and searching only to max depth, and fromInput ignoring a version without a package name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e02349423d
commit
9792dbe32f
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1,2 +1,2 @@
|
|||
# GitHub Sponsors configuration
|
||||
github: awalsh128
|
||||
github: awalsh128
|
||||
|
|
|
|||
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
7
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -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
3
.prettierrc.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"printWidth": 110
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
},
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || ""));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"extends": "../tsconfig.scripts.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
124
src/cache.ts
124
src/cache.ts
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
24
src/index.ts
24
src/index.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)])]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -17,7 +13,7 @@ import {
|
|||
MANIFEST_MAIN_FILENAME,
|
||||
} from "../src/cache.js";
|
||||
import { Manifest, ManifestEntry } from "../src/manifest.js";
|
||||
import { ActionPackageNames } from "../src/packages.js";
|
||||
import { ActionPackageNames, findInstallScript } from "../src/packages.js";
|
||||
|
||||
// Cache resolves its directory from os.homedir(), so point it at a temp dir.
|
||||
const { HOME_DIR } = await vi.hoisted(async () => {
|
||||
|
|
@ -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-")),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -35,6 +29,11 @@ vi.mock("node:os", async (importOriginal) => {
|
|||
const actual = await importOriginal<typeof import("node:os")>();
|
||||
return { ...actual, homedir: () => HOME_DIR };
|
||||
});
|
||||
// Restore looks for install scripts under the real "/", so stub the lookup.
|
||||
vi.mock("../src/packages.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/packages.js")>();
|
||||
return { ...actual, findInstallScript: vi.fn() };
|
||||
});
|
||||
vi.mock("@actions/cache", () => ({
|
||||
restoreCache: vi.fn(),
|
||||
saveCache: vi.fn(),
|
||||
|
|
@ -52,20 +51,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,42 +64,45 @@ 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), ""));
|
||||
}
|
||||
|
||||
/** Archive operations observed through the command runner. */
|
||||
/** Archive and install script operations observed through the command runner. */
|
||||
interface TarCalls {
|
||||
created: [archive: string, files: string[]][];
|
||||
extracted: string[];
|
||||
/** Number of commands run that extracted archives. */
|
||||
extractCommands?: number;
|
||||
/** Extractions and script runs, in the order they ran. */
|
||||
events?: string[];
|
||||
}
|
||||
|
||||
const argValue = (args: string[], flag: string) =>
|
||||
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
||||
|
||||
/** Records sudo tar calls, including the file list piped to tar create. */
|
||||
/** Records sudo tar and script 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;
|
||||
calls.events?.push(...args.slice(4).map((archive) => `extract ${path.basename(archive)}`));
|
||||
}
|
||||
// Install scripts run as `sh -x <script> <action>`.
|
||||
if (args[0] === "sh" && args[1] === "-x") {
|
||||
calls.events?.push(`script ${path.basename(args[2] ?? "")} ${args[3]}`);
|
||||
}
|
||||
return { cmdLine: "", stdout: "", stderr: "", exitCode: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
function createCache(calls: TarCalls = { created: [], extracted: [] }): Cache {
|
||||
|
|
@ -122,6 +115,7 @@ describe("Cache", () => {
|
|||
vi.mocked(ghcache.restoreCache).mockResolvedValue(KEY.hash);
|
||||
vi.mocked(ghcache.saveCache).mockResolvedValue(CACHE_ID);
|
||||
vi.mocked(ghcache.isFeatureAvailable).mockReturnValue(true);
|
||||
vi.mocked(findInstallScript).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -178,17 +172,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 +189,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 +202,110 @@ 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),
|
||||
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)],
|
||||
]);
|
||||
});
|
||||
it("loadAndRestore cache miss returns undefined without extracting", async () => {
|
||||
vi.mocked(ghcache.restoreCache).mockResolvedValue(undefined);
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
const manifest = await createCache(calls).loadAndRestore(KEY, false);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(calls.extracted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["main manifest missing", MANIFEST_MAIN_FILENAME, undefined],
|
||||
["all manifest missing", MANIFEST_ALL_FILENAME, undefined],
|
||||
["all manifest empty", MANIFEST_ALL_FILENAME, new Manifest(MANIFEST.created, [], KEY)],
|
||||
])("loadAndRestore %s returns undefined without extracting", async (_case, filename, replacement) => {
|
||||
await writeCache();
|
||||
const filePath = path.join(CACHE_DIR, filename);
|
||||
fs.rmSync(filePath);
|
||||
await replacement?.writeToFile(filePath);
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
const manifest = await createCache(calls).loadAndRestore(KEY, false);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(calls.extracted).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadAndRestore cache hit without cache directory logs it as unreadable and returns undefined", async () => {
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const errorSpy = vi.spyOn(logger, "error");
|
||||
const cache = new Cache({ run: vi.fn() } as never, logger);
|
||||
|
||||
const manifest = await cache.loadAndRestore(KEY, false);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to read cache directory contents"));
|
||||
});
|
||||
|
||||
it("loadAndRestore only packages without files runs no extract command", async () => {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
const metaOnly = new Manifest(MANIFEST.created, [new ManifestEntry(METAPACKAGE)], KEY);
|
||||
await metaOnly.writeToFile(path.join(CACHE_DIR, MANIFEST_MAIN_FILENAME));
|
||||
await metaOnly.writeToFile(path.join(CACHE_DIR, MANIFEST_ALL_FILENAME));
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
const manifest = await createCache(calls).loadAndRestore(KEY, false);
|
||||
|
||||
expect(manifest?.entries).toHaveLength(1);
|
||||
expect(calls.extractCommands).toBeUndefined();
|
||||
});
|
||||
|
||||
it("loadAndRestore with install scripts runs each found script after its archive with the dpkg action", async () => {
|
||||
await writeCache();
|
||||
const [first, second] = INSTALL_ORDER;
|
||||
vi.mocked(findInstallScript).mockImplementation(async (packageName, scriptType) =>
|
||||
packageName.name === first!.name ? `/var/lib/dpkg/info/${packageName.name}.${scriptType}` : undefined,
|
||||
);
|
||||
const calls: TarCalls = { created: [], extracted: [], events: [] };
|
||||
|
||||
await createCache(calls).loadAndRestore(KEY, true);
|
||||
|
||||
expect(calls.events?.slice(0, 4)).toEqual([
|
||||
`extract ${path.basename(archivePath(first!))}`,
|
||||
`script ${first!.name}.preinst install`,
|
||||
`script ${first!.name}.postinst configure`,
|
||||
`extract ${path.basename(archivePath(second!))}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("archiveAndSave cache service unavailable throws without archiving", async () => {
|
||||
vi.mocked(ghcache.isFeatureAvailable).mockReturnValue(false);
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
await expect(createCache(calls).archiveAndSave(MANIFEST)).rejects.toThrow("not available");
|
||||
expect(calls.created).toEqual([]);
|
||||
});
|
||||
|
||||
it("archiveAndSave archive already exists skips recreating it", async () => {
|
||||
const [existing, ...rest] = INSTALL_ORDER;
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
fs.writeFileSync(archivePath(existing!), "");
|
||||
const calls: TarCalls = { created: [], extracted: [] };
|
||||
|
||||
await createCache(calls).archiveAndSave(MANIFEST);
|
||||
|
||||
expect(calls.created.map(([archive]) => archive)).toEqual(rest.map(archivePath));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no cache ID", () => Promise.resolve(undefined), "no cache action ID returned"],
|
||||
["an Error", () => Promise.reject(new Error("upload failed")), "upload failed"],
|
||||
["a non-Error", () => Promise.reject("upload failed"), "upload failed"],
|
||||
])("archiveAndSave save returns %s throws a save error", async (_case, saveResult, message) => {
|
||||
vi.mocked(ghcache.saveCache).mockImplementation(saveResult as never);
|
||||
|
||||
await expect(createCache().archiveAndSave(MANIFEST)).rejects.toThrow(
|
||||
new RegExp(`^Failed to save cache: .*${message}`),
|
||||
);
|
||||
expect(
|
||||
written.entries.map((entry) => [
|
||||
entry.packageName.serialize(),
|
||||
entry.filepaths,
|
||||
]),
|
||||
).toEqual([[REQUESTED_PACKAGE.serialize(), filesOf(REQUESTED_PACKAGE)]]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
@ -56,4 +37,15 @@ describe("manifest", () => {
|
|||
expect(parsed.entries).toHaveLength(1);
|
||||
expect(parsed.entries[0]?.packageName.serialize()).toBe("curl:amd64=8.1.0");
|
||||
});
|
||||
|
||||
it("ManifestEntry fromJSON parsed entry returns package and sorted files", () => {
|
||||
const entry = new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), [
|
||||
"usr/bin/curl",
|
||||
"etc/curlrc",
|
||||
]);
|
||||
|
||||
const parsed = ManifestEntry.fromJSON(JSON.parse(JSON.stringify(entry)));
|
||||
|
||||
expect(parsed).toEqual(entry);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,14 +97,46 @@ 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();
|
||||
});
|
||||
|
||||
it("buildFileList installed paths missing on disk excludes them", async () => {
|
||||
const root = makeTempRoot("pkg-missing-file-");
|
||||
const presentFile = path.join(root, "present");
|
||||
fs.writeFileSync(presentFile, "", "utf8");
|
||||
const manager = {
|
||||
listInstalledFiles: vi.fn().mockResolvedValue([presentFile, path.join(root, "absent")]),
|
||||
};
|
||||
|
||||
const result = await buildFileList(PACKAGE_NAME, manager as any, root);
|
||||
|
||||
expect(result).toEqual([presentFile.slice(1)]);
|
||||
});
|
||||
|
||||
it("updateAptLists lists directory with files skips update", async () => {
|
||||
const aptListsPath = makeTempRoot("apt-lists-populated-");
|
||||
fs.mkdirSync(path.join(aptListsPath, "partial"));
|
||||
fs.writeFileSync(path.join(aptListsPath, "partial", "archive_Packages"), "", "utf8");
|
||||
const packageManager = { update: vi.fn() };
|
||||
|
||||
await updateAptLists(packageManager as any, { info: vi.fn() } as any, aptListsPath);
|
||||
|
||||
expect(packageManager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updateAptLists files only below max search depth runs update", async () => {
|
||||
const aptListsPath = makeTempRoot("apt-lists-deep-");
|
||||
const deepDir = path.join(aptListsPath, "a", "b", "c", "d", "e");
|
||||
fs.mkdirSync(deepDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(deepDir, "archive_Packages"), "", "utf8");
|
||||
const packageManager = { update: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await updateAptLists(packageManager as any, { info: vi.fn() } as any, aptListsPath);
|
||||
|
||||
expect(packageManager.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updateAptLists calls the package manager's update method", async () => {
|
||||
const aptListsPath = makeTempRoot("apt-lists-");
|
||||
|
||||
|
|
@ -142,11 +149,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);
|
||||
});
|
||||
|
|
@ -161,6 +164,12 @@ describe("packages", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("fromInput version without package name ignores the token", () => {
|
||||
const result = ActionPackageNames.fromInput("=1.0.0 curl");
|
||||
|
||||
expect(result.toArray()).toEqual([createPackageName("curl")]);
|
||||
});
|
||||
|
||||
it("fromInput returns an empty list when input is empty", () => {
|
||||
const result = ActionPackageNames.fromInput("");
|
||||
expect(result.toArray()).toEqual([]);
|
||||
|
|
@ -211,9 +220,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue