This commit is contained in:
awalsh128 2026-08-01 12:33:44 -07:00
parent c2de292527
commit fd8f3d3fd1
22 changed files with 1979 additions and 1961 deletions

View file

@ -1,8 +1,10 @@
name: Pull Requests
on:
pull_request:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# TODO Enable once v2 ready
# pull_request:
# types: [opened, synchronize, reopened, edited]
jobs:
check-branch:

View file

@ -32,8 +32,12 @@ There are three kinds of version labels you can use.
- `@latest` - This will give you the latest release.
- `@v#` - Major only will give you the latest release for that major version only (e.g. `v1`).
- Branch
- `@main-v2` - Most recent manual and automated tested code. Possibly unstable since it is pre-release.
- `@staging-v2` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code.
- version 1.x
- `@master` - Most recent manual and automated tested code. Possibly unstable since it is pre-release (publishes as `1.x.x`).
- `@staging` - Most recent automated tested code and can sometimes contain experimental features (no publish).
- version 2.x
- `@main-2` - Live and stable version, pre-production before action release (publishes as `2.x.x`).
- `@staging-2` - Possibly unstable and can sometimes contain experimental features (publishes as `2.x.x-rc#`).
### Inputs

View file

@ -1,27 +1,55 @@
# cache-apt-pkgs-action - Version 2 FAQ
<img src="logo.png" alt="Description" height="80" align="center" style="padding-right: 1em;"> <span style="font-size: 2em; font-weight: bold;">Cache APT Packages Action v2 :: FAQ</span>
## Why did you create a new version
## Why did you create a new version?
### Bash Scripts
Bash shell scripting grew in complexity and became very unreadable. The action needed higher level language idioms like array and map handling, complex pattern matching, and complex text processing.
Bash shell scripting grew in complexity and became very unreadable. The action needed higher level language
idioms like:
- arrays, map handling, complex pattern matching, and complex text processing;
- robust logging, debugging, other instrumentations; and
- avoiding cryptic shell and system environment considerations.
The lack of these
### Language Choice
Languages like Python, Go, Rust, etc. have performance and writeability trade offs that would greatly improve this action, but ultimately Typescript was chosen because:
Languages like Python, Go, Rust, etc. have performance and writeability trade offs that would also meet these
requirements, but ultimately Typescript was chosen because it:
- it is the most performant and well supported for GitHub actions;
- native strongly types libraries for action args and actions like caching;
- provides a balance of code comprehension for contributors and performance; and
- allows for better integration, CI, regression testing, and release workflows.
- is the most performant and well supported for GitHub actions;
- has native strongly typed libraries for actions maintained by GitHub like
- [`@actions/core`](https://github.com/actions/toolkit/tree/main/packages/core) for runtime environment, and
- other common services like [`@actions/cache`](https://github.com/actions/toolkit/tree/main/packages/cache)
and [`@actions/artifact`](https://github.com/actions/toolkit/tree/main/packages/artifact);
- requires no binary compilation;
- provides a balance of code comprehension and performance for contributors; and
- allows for better continuous integration, release workflow, hermetic unit testing, and smoke testing.
## Where did all the APT functionality go?
Since this has been a complex part of the codebase, I decided to separate the concern as its own library. This can now be found in [npm/ts-apt](https://www.npmjs.com/package/ts-apt). For ease of use there is an option to bring in the workspace for inline source so it can be debugged and then committed to the [ts-apt](http://github.com/awalsh128/ts-apt) repository.
Since this has been a complex part of the codebase, I decided to separate the concern as its own library. This
can now be found at [`npm/ts-apt`](https://www.npmjs.com/package/ts-apt) with the sourcecode in the
[`awalsh128/ts-apt`](http://github.com/awalsh128/ts-apt) repository.
For ease of use there is an option to bring in the workspace for inline source via `npm run dev:tsapt:[un]link`
so it can be debugged and then PR'd to the [awalsh128/ts-apt](http://github.com/awalsh128/ts-apt)
repository.
## How do I start using this?
> [!IMPORTANT]
> This is still in beta so use at your own risk!
> This is in the early phase of development. Use at your own risk!
For now you can use the `v2.*` release tags (pinning also supported). Once this version has had time to soak it will be released as the latest. `v1.*` will still get updates but in maintenance mode.
**No change is needed and the action signatures are backwards compatible.**
- Just change to the latest `v2.*` in your GitHub workflow.
- `v1.*` will still get patch updates but is in maintenance mode.
## Some internal changes
- Logging uses native GitHub calls that will result in run annotations.
- Output will tend to be more verbose but this will be tuned as this version matures.
- All the metadata generated like manifests, the cache key, and logs are now stored as artifacts.
- Debug mode can either be specified via an input parameter `debug` or [as part of the repository settings](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging).

2449
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -98,6 +98,7 @@
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.1",
"@types/node": "^24.1.0",
"@types/tar": "^6.1.13",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"@vitest/coverage-v8": "^4.1.9",
@ -109,7 +110,7 @@
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.12.0",
"typescript": "^6.0.3",
"vitest": "^4.1.9",
"vitest": "^4.1.10",
"zod": "^4.4.3"
}
}

View file

@ -1,5 +1,9 @@
import { createPackageManager, type CommandRunner } from "ts-apt";
import { Cache, CacheKey } from "./cache.ts";
import {
createPackageManager,
type CommandRunner,
type PackageName,
} from "ts-apt";
import { Cache, CacheKey } from "./cache.js";
import { Manifest } from "./manifest.js";
import { ActionPackageNames } from "./packages.js";
import winston from "winston";
@ -40,34 +44,6 @@ export class ActionRunner {
this.logger = logger;
}
/**
* Applies configured behavior when package input resolves to an empty set.
*
* @param behavior Empty-package handling strategy.
* @param packages Normalized package list.
* @returns Nothing.
* @throws Error when behavior is error and packages is empty.
*/
validateEmptyPackages(
behavior: EmptyPackageBehavior,
packages: ActionPackageNames,
): void {
if (packages.length > 0) {
return;
}
if (behavior === "ignore") {
return;
}
if (behavior === "warn") {
process.stdout.write("::warning::Packages argument is empty.\n");
return;
}
throw new Error("Packages argument is empty.");
}
/**
* Converts manifest entries into the action output CSV format.
*
@ -108,6 +84,7 @@ export class ActionRunner {
this.logger,
);
const packageInfos = await installManager.install(packageNames.toArray());
manifest = Manifest.from(new Date(), cacheKey, packageInfos);
await this.cache.archiveAndSave(cacheKey, packageInfos);
}

View file

@ -5,10 +5,14 @@ import * as crypto from "node:crypto";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { ActionPackageNames, findInstallScript } from "./packages.ts";
import { Manifest, ManifestEntry } from "./manifest.ts";
import { CommandRunner, PackageInfo, PackageName } from "ts-apt/types.js";
import { deserializePackageName } from "ts-apt/package.ts";
import { ActionPackageNames, findInstallScript } from "./packages.js";
import { Manifest, ManifestEntry } from "./manifest.js";
import {
deserializePackageName,
CommandRunner,
PackageInfo,
PackageName,
} from "ts-apt";
export const CACHE_DEFAULT_DIRNAME = "cache-apt-pkgs";
export const CACHE_KEY_FILENAME = "cache_key.md5";
@ -41,7 +45,10 @@ export class CacheKey {
this.forceUpdateIncrement = forceUpdateIncrement;
this.arch = arch;
this.packageNames = packageNames;
this.hash = crypto.createHash("md5").update(this.toJSON()).digest("hex");
this.hash = crypto
.createHash("md5")
.update(this.toJSON(false))
.digest("hex");
}
/**
@ -64,14 +71,28 @@ export class CacheKey {
/**
* Serializes cache key fields to a stable, human-readable format.
*
* NOTE: Object is always stable JSON stringification for consistent hashing.
*
* @param readable Whether to pretty-print the JSON output. Defaults to false.
* @returns Serialized cache key components.
*/
toJSON(): string {
return JSON.stringify(this, null, 2);
toJSON(readable: boolean = false): string {
// Helper to sort keys recursively
const sortObj = (o: any): any => {
if (o === null || typeof o !== "object") return o;
if (Array.isArray(o)) return o.map(sortObj);
return Object.keys(o)
.sort()
.reduce((acc, key) => {
acc[key] = sortObj(o[key]);
return acc;
}, {} as any);
};
return JSON.stringify(sortObj(this), null, readable ? 2 : 0);
}
toString(): string {
return `${this.hash} (key input: ${JSON.stringify(this)})`;
return `${this.hash} (key input: ${this.toJSON(true)})`;
}
}
@ -97,7 +118,7 @@ export class Cache {
private async runInstallScripts(archiveFilename: string): Promise<void> {
const serializePackageName = archiveFilename.replace(/\.tar$/, "");
const packageName = deserializePackageName(serializePackageName);
const packageName = deserializePackageName(serializePackageName)!;
const runScript = async (
packageName: PackageName,
@ -140,31 +161,29 @@ export class Cache {
return undefined;
}
const logFileError = (prefixMessage: string) => {
const logFileError = async (prefixMessage: string) => {
const contents = await fs
.readdir(this.path)
.then((entries) => entries.join("\n"))
.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` +
fs
.readdir(this.path)
.then(
(entries) => `Cache directory contents:\n${entries.join("\n")}`,
)
.catch(
(reason: any) =>
`Unable to read cache directory contents: ${reason}`,
),
`Cache directory contents:\n${contents}`,
);
};
this.logger.info(`Cache hit for key ${key.hash}, restoring...`);
const manifestPath = path.join(this.path, MANIFEST_MAIN_FILENAME);
if (
!fs
!(await fs
.access(manifestPath, fs.constants.R_OK)
.then(() => true)
.catch(() => false)
.catch(() => false))
) {
logFileError(`Manifest file not found at ${manifestPath}`);
await logFileError(`Manifest file not found at ${manifestPath}`);
return undefined;
}
const archives = await fs
@ -175,7 +194,9 @@ export class Cache {
.then((entries: string[]) => entries.sort());
if (archives.length === 0) {
logFileError(`No archive files found in cache directory ${this.path}`);
await logFileError(
`No archive files found in cache directory ${this.path}`,
);
return undefined;
}
@ -191,6 +212,8 @@ export class Cache {
await this.runInstallScripts(archiveFilename);
}
}
return await Manifest.readFromFile(manifestPath);
}
private async createArchive(manifestEntry: ManifestEntry): Promise<void> {
@ -232,10 +255,10 @@ export class Cache {
const allEntries = manifest.entries;
const packages = manifest.cacheKey.packageNames;
const entriesByName = new Map(
allEntries.map((entry) => [entry.packageName, entry]),
allEntries.map((entry) => [entry.packageName.serialize(), entry]),
);
const mainEntries = packages.toArray().map((pkg) => {
const installed = entriesByName.get(pkg);
const installed = entriesByName.get(pkg.serialize());
return new ManifestEntry(pkg, installed?.filepaths ?? []);
});
@ -295,7 +318,7 @@ export class Cache {
try {
const cacheId = await ghcache.saveCache(
manifest.entries.flatMap((entry) => entry.filepaths),
[this.path],
manifest.cacheKey.hash,
);
if (cacheId === undefined) {

View file

@ -1,7 +1,7 @@
import * as core from "@actions/core";
import { runAction, type ActionInputs } from "./action.js";
import { DefaultCommandRunner } from "ts-apt";
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.ts";
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.js";
import { Instruments } from "./instrumentation.js";
/**
@ -59,7 +59,7 @@ async function main(): Promise<void> {
const outputs = await runAction(
inputs,
commandRunner,
new Cache(commandRunner, instruments.appLogger, cacheDir),
new Cache(commandRunner, instruments.appLogger, instruments.cacheDir),
instruments.appLogger,
);

View file

@ -8,7 +8,7 @@ import {
CACHE_KEY_FILENAME,
MANIFEST_ALL_FILENAME,
MANIFEST_MAIN_FILENAME,
} from "./cache.ts";
} from "./cache.js";
const APP_LOG_FILENAME = "capa_app.log";
const EXEC_LOG_FILENAME = "capa_exec.log";
@ -23,15 +23,23 @@ function createStream(
});
}
/**
* Manages artifact creation and upload.
*/
export class Artifacts {
constructor(
/** Directory that all the artifacts are stored. */
readonly dir: string,
/** Filenames of monitored artifacts. */
readonly artifactFilenames: string[],
/** 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()}`,
) {}
/** Upload artifacts to GitHub Actions via [@actions/artifact]. */
upload(): string {
const client = new DefaultArtifactClient();
client.uploadArtifact(
@ -48,6 +56,7 @@ export class Artifacts {
}
}
/** Create the logger used for command line execution */
function createExecLogger(debug: boolean, filepath: string): winston.Logger {
const logger = winston.createLogger({
level: debug ? "debug" : "info",
@ -60,6 +69,7 @@ function createExecLogger(debug: boolean, filepath: string): winston.Logger {
return logger;
}
/** Create the logger used for GitHub Actions integration. */
function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
const logger = winston.createLogger({
level: debug ? "debug" : "info",
@ -87,11 +97,17 @@ function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
return logger;
}
/** Container for all instrumentation: telemetry, logging, and artifacts generated */
export class Instruments {
/** Cache directory where all artifacts are stored, including cached packages. */
readonly cacheDir: string;
/** Whether debug is enabled which affects artifacts otherwise not. */
readonly debug: boolean;
/** Manages artifact creation and upload. */
readonly artifacts: Artifacts;
/** Logger used for GitHub Actions integration. */
readonly appLogger: winston.Logger;
/** Logger used for command line execution. */
readonly execLogger: winston.Logger;
constructor(cacheDir: string, debug: boolean, artifacts?: Artifacts) {

View file

@ -1,7 +1,6 @@
import fs from "node:fs";
import { CacheKey } from "./cache.ts";
import { createPackageName, packageNameFromJSON } from "ts-apt/package.ts";
import { PackageInfo, PackageName } from "ts-apt/types.ts";
import { CacheKey } from "./cache.js";
import { packageNameFromJSON, PackageInfo, PackageName } from "ts-apt";
export class ManifestEntry {
readonly packageName: PackageName;
@ -42,22 +41,22 @@ export class Manifest {
);
}
static async readFromFile(filePath: string): Promise<Manifest> {
const content = await fs.promises.readFile(filePath, "utf-8");
return Manifest.fromJSON(JSON.parse(content));
}
static from(
date: Date,
cacheKey: CacheKey,
packageInfos: PackageInfo[],
): Manifest {
const entries = packageInfos.map(
(info) =>
new ManifestEntry(createPackageName(info.name, info.version), []),
(info: PackageInfo) => new ManifestEntry(info.name, []),
);
return new Manifest(date, entries, cacheKey);
}
async readFromFile(filePath: string): Promise<Manifest> {
return Manifest.fromJSON(await fs.promises.readFile(filePath, "utf-8"));
}
async writeToFile(filePath: string): Promise<void> {
await fs.promises.writeFile(
filePath,

View file

@ -1,7 +1,11 @@
import * as fs from "fs";
import type { PackageName, PackageManager } from "ts-apt/types.ts";
import path from "path";
import { createPackageName, deserializePackageName } from "ts-apt/package.ts";
import {
createPackageName,
deserializePackageName,
type PackageName,
type PackageManager,
} from "ts-apt";
import winston from "winston";
/**
@ -14,6 +18,7 @@ import winston from "winston";
export async function buildFileList(
packageName: PackageName,
packageManager: PackageManager,
root: string = "/",
): Promise<string[]> {
// Converts absolute paths to tar-relative paths.
const tarRelativePath = (filePath: string) =>
@ -30,8 +35,8 @@ export async function buildFileList(
})
.map((filePath) => tarRelativePath(filePath));
const preinst = await findInstallScript(packageName, "preinst", "/");
const postinst = await findInstallScript(packageName, "postinst", "/");
const preinst = await findInstallScript(packageName, "preinst", root);
const postinst = await findInstallScript(packageName, "postinst", root);
if (preinst) {
files.push(tarRelativePath(preinst));
@ -40,7 +45,7 @@ export async function buildFileList(
files.push(tarRelativePath(postinst));
}
return files.sort((a, b) => a.localeCompare(b));
return [...new Set(files)].sort((a, b) => a.localeCompare(b));
}
/**
@ -61,9 +66,7 @@ export async function findInstallScript(
return undefined;
}
const pattern = new RegExp(
`^${packageName.serialize()}(:.*)?\\.${extension}$`,
);
const pattern = new RegExp(`^${packageName.name}(:.*)?\\.${extension}$`);
const matches = fs
.readdirSync(scriptsDir)
.filter((entry) => pattern.test(entry))
@ -76,49 +79,87 @@ export async function findInstallScript(
return path.join(scriptsDir, candidate);
}
/**
* Resolves a concrete version for an unpinned package name.
*
* @param packageManager ts-apt package manager instance used for metadata lookup.
* @param packageName Package name with or without a version pin.
* @returns Resolved package version.
* @throws Error when no version can be resolved.
*/
export async function resolvePackageVersion(
packageManager: PackageManager,
packageName: PackageName,
): Promise<string> {
const packageInfo = await packageManager.getPackageInfo([packageName]);
const version = packageInfo[0]?.version;
if (!version) {
throw new Error(
`Unable to resolve package version for '${packageName.serialize()}'.`,
);
}
return version;
}
export class ActionPackageNames {
private readonly items: PackageName[];
private constructor(items: PackageName[]) {
this.items = items.sort();
this.items = items.sort((a, b) => a.compareTo(b));
}
static fromInput(serializedPackageNames: string): ActionPackageNames {
const names = serializedPackageNames
.replace(/[,\\]/g, " ")
const tokens = serializedPackageNames
.replace(/[,\\\n]/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((part) => deserializePackageName(part.trim()));
.map((part) => part.trim())
.filter((part) => part.length > 0);
const names: PackageName[] = [];
for (let index = 0; index < tokens.length; ) {
const current = tokens[index]!;
const next = tokens[index + 1];
const splitNameArch = (namePart: string) => {
const [name = "", arch] = namePart.split(":", 2);
return { name, arch };
};
try {
if (current.includes("=")) {
const [namePart, ...versionParts] = current.split("=");
const version = versionParts.join("=").trim();
if (namePart === undefined || namePart.trim() === "") {
index += 1;
continue;
}
const split = splitNameArch(namePart.trim());
let arch = split.arch;
if (
arch === undefined &&
next !== undefined &&
!next.includes("=") &&
next.trim() !== ""
) {
arch = next.trim();
index += 1;
}
names.push(
createPackageName(
split.name,
version === "" ? undefined : version,
arch,
),
);
index += 1;
continue;
}
const split = splitNameArch(current);
names.push(createPackageName(split.name, undefined, split.arch));
} catch {
// Ignore invalid package tokens.
}
index += 1;
}
return new ActionPackageNames(names);
}
static fromJSON(json: any): ActionPackageNames {
const items = (json as any[]).map((item: any) =>
createPackageName(item.name, item.version, item.rc),
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),
);
return new ActionPackageNames(items);
}
@ -127,7 +168,7 @@ export class ActionPackageNames {
return this.items.length;
}
toArray(): ReadonlyArray<PackageName> {
toArray(): readonly PackageName[] {
return this.items;
}
}
@ -141,8 +182,8 @@ export class ActionPackageNames {
export async function updateAptLists(
packageManager: PackageManager,
logger: winston.Logger,
aptListsPath: string = "/var/lib/apt/lists",
): Promise<void> {
const aptListsPath = "/var/lib/apt/lists";
const maxDepth = 5;
const search = async (

View file

@ -1,380 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import winston from "winston";
vi.mock("@actions/cache", () => ({
restoreCache: vi.fn(),
saveCache: vi.fn(),
}));
vi.mock("ts-apt", () => ({
createPackageManager: vi.fn(),
DefaultCommandRunner: vi.fn().mockImplementation(() => ({
run: vi.fn(),
})),
}));
import * as cacheMod from "@actions/cache";
import { createPackageManager, DefaultCommandRunner } from "ts-apt";
import { Manifest } from "../src/manifest.js";
import { CacheKey } from "../src/cache.ts";
import {
ActionPackageName,
ActionRunner,
runAction as runActionEntry,
} from "../src/action.js";
describe("ActionRunner coverage", () => {
afterEach(() => {
vi.restoreAllMocks();
});
function createRunner() {
const commandRunner = {
run: vi.fn(),
};
const logger = winston.createLogger({ silent: true });
const tarModule = {
create: vi.fn(),
extract: vi.fn(),
};
return {
runner: new ActionRunner(
commandRunner as never,
tarModule as never,
logger,
),
commandRunner,
tarModule,
logger,
};
}
it("parseBoolean invalid value expected throw", () => {
const { runner } = createRunner();
expect(() => runner.parseBoolean("invalid", "debug")).toThrow(
/must be either true or false/,
);
});
it("normalizeInputPackages escaped separators expected sorted output", () => {
const { runner } = createRunner();
expect(runner.normalizeInputPackages(" z, a \\ b ")).toEqual([
"a",
"b",
"z",
]);
});
it("validateEmptyPackages ignore empty expected no throw", () => {
const { runner } = createRunner();
expect(() => runner.validateEmptyPackages("ignore", [])).not.toThrow();
});
it("findInstallScript missing directory expected undefined", () => {
const { runner } = createRunner();
vi.spyOn(fs, "existsSync").mockReturnValue(false);
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBeUndefined();
});
it("findInstallScript matching scripts expected first sorted path", () => {
const { runner } = createRunner();
vi.spyOn(fs, "existsSync").mockReturnValue(true);
vi.spyOn(fs, "readdirSync").mockReturnValue([
"curl:amd64.preinst",
"curl.preinst",
"curl.postinst",
] as never);
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBe(
"/tmp/var/lib/dpkg/info/curl:amd64.preinst",
);
});
it("buildFileListForPackage mixed files and scripts expected unique sorted tar paths", async () => {
const { runner } = createRunner();
const packageManager = {
listInstalledFiles: vi
.fn()
.mockResolvedValue(["/a", "/b", "/missing", "/a"]),
};
vi.spyOn(fs, "existsSync").mockImplementation((p) => p !== "/missing");
vi.spyOn(fs, "lstatSync").mockImplementation(
(p) =>
({
isFile: () => p === "/a",
isSymbolicLink: () => p === "/b",
}) as fs.Stats,
);
vi.spyOn(runner, "findInstallScript")
.mockReturnValueOnce("/var/lib/dpkg/info/curl.preinst")
.mockReturnValueOnce("/var/lib/dpkg/info/curl.postinst");
await expect(
runner.buildFileListForPackage(
packageManager as never,
new ActionPackageName("curl"),
),
).resolves.toEqual([
"a",
"b",
"var/lib/dpkg/info/curl.postinst",
"var/lib/dpkg/info/curl.preinst",
]);
});
it("installAndCachePackages new archives expected tar create and manifests", async () => {
const { runner, tarModule } = createRunner();
const packageManager = {
install: vi.fn().mockResolvedValue([{ name: "curl", version: "1.0" }]),
update: vi.fn().mockResolvedValue(undefined),
};
vi.spyOn(runner, "updateAptLists").mockResolvedValue(undefined);
vi.spyOn(runner, "buildFileListForPackage").mockResolvedValue([
"usr/bin/curl",
]);
vi.spyOn(fs, "existsSync").mockReturnValue(false);
await runner.installAndCachePackages(
"/cache",
[new ActionPackageName("curl")],
packageManager as never,
new CacheKey("", "4", "x86_64", ["curl"]),
);
expect(tarModule.create).toHaveBeenCalledOnce();
expect(fs.existsSync("/cache/manifest_main.json")).toBe(true);
expect(fs.existsSync("/cache/manifest_all.json")).toBe(true);
});
it("restorePackages install scripts enabled expected extract and script runs", async () => {
const { runner, commandRunner, tarModule } = createRunner();
vi.spyOn(fs, "readdirSync").mockReturnValue([
"curl=1.0.tar",
"notes.txt",
] as never);
vi.spyOn(runner, "findInstallScript")
.mockReturnValueOnce("/preinst")
.mockReturnValueOnce("/postinst");
await runner.restorePackages("/cache", true);
expect(tarModule.extract).toHaveBeenCalledWith({
cwd: "/",
file: "/cache/curl=1.0.tar",
preservePaths: true,
});
expect(commandRunner.run).toHaveBeenNthCalledWith(1, "sudo", [
"sh",
"-x",
"/preinst",
"install",
]);
expect(commandRunner.run).toHaveBeenNthCalledWith(2, "sudo", [
"sh",
"-x",
"/postinst",
"configure",
]);
});
it("runAction version contains spaces expected throw", async () => {
const { runner } = createRunner();
await expect(
runner.runAction({
packages: "curl",
version: "bad version",
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
}),
).rejects.toThrow(/cannot contain spaces/);
});
it("runAction empty normalized packages expected empty outputs and manifests", async () => {
const { runner } = createRunner();
const cacheDir = path.join(os.tmpdir(), "action-empty-cache");
vi.mocked(createPackageManager).mockResolvedValue({} as never);
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([]);
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
await expect(
runner.runAction({
packages: "",
version: "",
executeInstallScripts: false,
emptyPackagesBehavior: "ignore",
debug: false,
}),
).resolves.toEqual({
cacheHit: false,
packageVersionList: "",
allPackageVersionList: "",
});
expect(fs.existsSync(path.join(cacheDir, "manifest_main.json"))).toBe(true);
expect(fs.existsSync(path.join(cacheDir, "manifest_all.json"))).toBe(true);
});
it("runAction cache miss expected install and save cache", async () => {
const { runner } = createRunner();
const cacheDir = path.join(os.tmpdir(), "action-cache-miss");
vi.mocked(createPackageManager)
.mockResolvedValueOnce({} as never)
.mockResolvedValueOnce({ install: vi.fn() } as never);
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
new ActionPackageName("curl", "1.0"),
]);
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
vi.spyOn(runner, "installAndCachePackages").mockResolvedValue(undefined);
vi.mocked(cacheMod.restoreCache).mockResolvedValue(undefined);
vi.mocked(cacheMod.saveCache).mockResolvedValue(1);
vi.spyOn(Manifest, "readFromFile")
.mockReturnValueOnce(
Manifest.deserialize(
JSON.stringify({
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
cacheKeyInput: "",
cacheKey: "",
forceUpdateIncrement: "4",
arch: "x86_64",
}),
),
)
.mockReturnValueOnce(
Manifest.deserialize(
JSON.stringify({
entries: [
{ name: "curl", version: "1.0", filepaths: [] },
{ name: "dep", version: "1.0", filepaths: [] },
],
cacheKeyInput: "",
cacheKey: "",
forceUpdateIncrement: "4",
arch: "x86_64",
}),
),
);
await expect(
runner.runAction({
packages: "curl",
version: "v1",
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
}),
).resolves.toEqual({
cacheHit: false,
packageVersionList: "curl=1.0",
allPackageVersionList: "curl=1.0,dep=1.0",
});
expect(runner.installAndCachePackages).toHaveBeenCalledOnce();
expect(cacheMod.saveCache).toHaveBeenCalledWith(
[cacheDir],
"cache-apt-pkgs_key",
);
});
it("runAction cache hit expected restore packages", async () => {
const { runner } = createRunner();
const cacheDir = path.join(os.tmpdir(), "action-cache-hit");
vi.mocked(createPackageManager).mockResolvedValue({} as never);
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
new ActionPackageName("curl", "1.0"),
]);
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
vi.spyOn(runner, "restorePackages").mockResolvedValue(undefined);
vi.mocked(cacheMod.restoreCache).mockResolvedValue("cache-apt-pkgs_key");
vi.spyOn(Manifest, "readFromFile")
.mockReturnValueOnce(
Manifest.deserialize(
JSON.stringify({
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
cacheKeyInput: "",
cacheKey: "",
forceUpdateIncrement: "4",
arch: "x86_64",
}),
),
)
.mockReturnValueOnce(
Manifest.deserialize(
JSON.stringify({
entries: [
{ name: "curl", version: "1.0", filepaths: [] },
{ name: "dep", version: "1.0", filepaths: [] },
],
cacheKeyInput: "",
cacheKey: "",
forceUpdateIncrement: "4",
arch: "x86_64",
}),
),
);
await expect(
runner.runAction({
packages: "curl",
version: "v1",
executeInstallScripts: true,
emptyPackagesBehavior: "error",
debug: false,
}),
).resolves.toEqual({
cacheHit: true,
packageVersionList: "curl=1.0",
allPackageVersionList: "curl=1.0,dep=1.0",
});
expect(runner.restorePackages).toHaveBeenCalledWith(cacheDir, true);
});
it("runAction wrapper valid inputs expected delegated runner output", async () => {
const logger = winston.createLogger({ silent: true });
const delegated = {
cacheHit: false,
packageVersionList: "curl=1.0",
allPackageVersionList: "curl=1.0,dep=1.0",
};
const runSpy = vi
.spyOn(ActionRunner.prototype, "runAction")
.mockResolvedValue(delegated);
await expect(
runActionEntry(
{
packages: "curl",
version: "v1",
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
},
{ run: vi.fn() } as never,
logger,
),
).resolves.toEqual(delegated);
expect(DefaultCommandRunner).toHaveBeenCalledWith(logger, logger);
expect(runSpy).toHaveBeenCalledOnce();
});
});

View file

@ -1,119 +1,84 @@
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import winston from "winston";
import { Manifest } from "../src/manifest.js";
import { CacheKey } from "../src/cache.js";
import { ActionPackageNames } from "../src/packages.js";
import { createPackageManager } from "ts-apt";
import { ActionRunner } from "../src/action.js";
vi.mock("ts-apt", async (importOriginal) => {
const actual = await importOriginal<typeof import("ts-apt")>();
return {
...actual,
createPackageManager: vi.fn(),
};
});
describe("ActionRunner", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(createPackageManager).mockReset();
});
function createRunner() {
const cache = {
loadAndRestore: vi.fn(),
archiveAndSave: vi.fn(),
};
const commandRunner = {
run: vi.fn(),
};
const logger = winston.createLogger({ silent: true });
const tarModule = {
create: vi.fn(),
extract: vi.fn(),
};
return {
runner: new ActionRunner(
commandRunner as never,
tarModule as never,
logger,
),
runner: new ActionRunner(cache as never, commandRunner as never, logger),
cache,
commandRunner,
};
}
it("resolves package versions from package manager metadata", async () => {
const { runner } = createRunner();
const packageManager = {
getPackageInfo: vi.fn().mockResolvedValue([{ version: "1.2.3" }]),
};
it("returns cache-hit outputs when manifest is restored", async () => {
const { runner, cache } = createRunner();
const packageNames = ActionPackageNames.fromInput("curl git");
const cacheKey = new CacheKey("v1", "0", process.arch, packageNames);
const restored = new Manifest(new Date(), [], cacheKey);
cache.loadAndRestore.mockResolvedValue(restored);
await expect(
runner.resolvePackageVersion(packageManager as never, "git"),
).resolves.toBe("1.2.3");
const result = await runner.runAction({
packages: "curl git",
version: "v1",
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
});
expect(result.cacheHit).toBe(true);
expect(cache.archiveAndSave).not.toHaveBeenCalled();
});
it("fails when package metadata does not contain a version", async () => {
const { runner } = createRunner();
const packageManager = {
getPackageInfo: vi.fn().mockResolvedValue([{}]),
};
it("archives installed packages on cache miss", async () => {
const { runner, cache } = createRunner();
cache.loadAndRestore.mockResolvedValue(undefined);
vi.mocked(createPackageManager).mockResolvedValue({
install: vi.fn().mockResolvedValue([
{
name: {
serialize: () => "curl=1.0.0",
},
},
]),
} as never);
await expect(
runner.resolvePackageVersion(packageManager as never, "git"),
).rejects.toThrow(/Unable to resolve package version/);
});
await runner.runAction({
packages: "curl",
version: "v1",
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
});
it("normalizes packages and fills in missing versions", async () => {
const { runner } = createRunner();
vi.spyOn(runner, "resolvePackageVersion")
.mockResolvedValueOnce("8.0")
.mockResolvedValueOnce("2.39");
await expect(
runner.normalizePackagesWithVersions({} as never, "git curl=8.1"),
).resolves.toEqual(["curl=8.1", "git=8.0"]);
});
it("warns instead of throwing for empty packages when behavior is warn", () => {
const { runner } = createRunner();
const writeSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
expect(() => runner.validateEmptyPackages("warn", [])).not.toThrow();
expect(writeSpy).toHaveBeenCalledWith(
"::warning::Packages argument is empty.\n",
);
});
it("throws for empty packages when behavior is error", () => {
const { runner } = createRunner();
expect(() => runner.validateEmptyPackages("error", [])).toThrow(
/Packages argument is empty/,
);
});
it("returns the cache root under the current home directory", () => {
const { runner } = createRunner();
expect(runner.getCacheRoot()).toBe(
path.join(os.homedir(), "cache-apt-pkgs"),
);
});
it("hashes runner cache keys using the current architecture", async () => {
const { runner, commandRunner } = createRunner();
commandRunner.run.mockResolvedValue({ stdout: "arm64\n" });
await expect(runner.getCacheKey(["curl=1", "git=2"], "v1")).resolves.toBe(
"cache-apt-pkgs_36cfe31d08e34e7bd87b39c0e0145ece",
);
});
it("removes versions from package specifiers", () => {
const { runner } = createRunner();
expect(runner.packageSpecifierToName("curl=8.1")).toBe("curl");
expect(runner.packageSpecifierToName("git")).toBe("git");
});
it("strips leading slashes from tar paths", () => {
const { runner } = createRunner();
expect(runner.tarRelativePath("/var/cache/apt/pkg.tar")).toBe(
"var/cache/apt/pkg.tar",
);
expect(runner.tarRelativePath("relative/file")).toBe("relative/file");
expect(cache.archiveAndSave).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,48 +1,74 @@
import { describe, expect, it } from "vitest";
import {
ActionPackageName,
normalizeInputPackages,
parseBoolean,
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";
import { ActionPackageNames } from "../src/packages.js";
describe("non runner functions", () => {
describe("runAction", () => {
const PKG_NAME = "curl";
const PKG_VER = "1.2.3";
const PKG_DISTRO = "focal";
const PKG2_NAME = "git";
const PKG3_NAME = "jq";
it("normalizes package list syntax", () => {
const input = ` ${PKG2_NAME}, ${PKG_NAME} \\\n ${PKG3_NAME} `;
expect(normalizeInputPackages(input)).toEqual([
PKG_NAME,
PKG2_NAME,
PKG3_NAME,
]);
});
it("returns expected outputs for cache hit", async () => {
const inputs: ActionInputs = {
packages: `${PKG_NAME},${PKG2_NAME},${PKG3_NAME}`,
version: PKG_VER,
executeInstallScripts: false,
emptyPackagesBehavior: "error",
debug: false,
};
it("parses true/false values", () => {
expect(parseBoolean("true", "debug")).toBe(true);
expect(parseBoolean("false", "debug")).toBe(false);
});
const packageNames = ActionPackageNames.fromInput(inputs.packages);
const cacheKey = new CacheKey(
inputs.version,
"0",
process.arch,
packageNames,
);
const manifest = new Manifest(
new Date(),
[
new ManifestEntry(createPackageName(PKG_NAME, PKG_VER, undefined), []),
new ManifestEntry(createPackageName(PKG2_NAME, PKG_VER, undefined), []),
new ManifestEntry(createPackageName(PKG3_NAME, PKG_VER, undefined), []),
],
cacheKey,
);
it("fails for invalid booleans", () => {
expect(() => parseBoolean("TRUE", "debug")).toThrow();
});
const mockCache = {
loadAndRestore: async () => manifest,
archiveAndSave: async () => {},
};
it("serializes ActionPackageName with no version", () => {
expect(new ActionPackageName(PKG_NAME).serialize()).toEqual(PKG_NAME);
});
const mockCommandRunner = {
run: async () => {},
};
it("serializes ActionPackageName with version", () => {
expect(new ActionPackageName(PKG_NAME, PKG_VER).serialize()).toEqual(
`${PKG_NAME}=${PKG_VER}`,
const mockLogger = {
info: () => {},
error: () => {},
debug: () => {},
};
const outputs: ActionOutputs = await runAction(
inputs,
mockCommandRunner as never,
mockCache as never,
mockLogger as never,
);
expect(outputs.cacheHit).toBe(true);
expect(outputs.packageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
);
expect(outputs.allPackageVersionList).toBe(
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
);
});
it("serializes ActionPackageName with version and distro", () => {
expect(
new ActionPackageName(PKG_NAME, PKG_VER, PKG_DISTRO).serialize(),
).toEqual(`${PKG_NAME}=${PKG_VER}`);
});
});

121
test/cache.test.ts Normal file
View file

@ -0,0 +1,121 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Cache, CacheKey } from "../src/cache.js";
import { ActionPackageNames } from "../src/packages.js";
const ARCH = "arm64";
const CACHE_VER = "v1";
const FORCE_UPDATE_INCREMENT = "4";
const INPUT_PACKAGE_NAMES = ["curl=1", "git=2"];
const SERIALIZED_INPUT_PACKAGE_NAMES = INPUT_PACKAGE_NAMES.join(" ");
const HASH = "6753d4609b4f220748c84ac0893bd97d";
const CACHE_KEY_JSON =
'{"arch":"arm64","forceUpdateIncrement":"4","hash":"6753d4609b4f220748c84ac0893bd97d","packageNames":{"items":[{"name":"curl","version":"1"},{"name":"git","version":"2"}]},"version":"v1"}';
describe("cache", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("CacheKey", () => {
it("creates a cache key with the expected hash", () => {
const key = new CacheKey(
CACHE_VER,
FORCE_UPDATE_INCREMENT,
ARCH,
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
);
expect(key.hash).toBe(HASH);
});
it("creates a cache key of unordered package names with the expected hash", () => {
const key = new CacheKey(
CACHE_VER,
FORCE_UPDATE_INCREMENT,
ARCH,
ActionPackageNames.fromInput(INPUT_PACKAGE_NAMES.reverse().join(" ")),
);
expect(key.hash).toBe(HASH);
});
it("creates a cache key with the expected hash", () => {
const key = new CacheKey(
CACHE_VER,
FORCE_UPDATE_INCREMENT,
ARCH,
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
);
expect(key.hash).toBe(HASH);
});
it("JSON roundtrips cache keys", () => {
const key = new CacheKey(
CACHE_VER,
FORCE_UPDATE_INCREMENT,
ARCH,
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
);
expect(key.toJSON()).toBe(CACHE_KEY_JSON);
expect(CacheKey.fromJSON(key.toJSON())).toEqual(
new CacheKey(
CACHE_VER,
FORCE_UPDATE_INCREMENT,
ARCH,
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
),
);
});
});
// it("rejects invalid serialized cache keys", () => {
// expect(() => CacheKey.fromJSON("invalid")).toThrow(
// /Invalid serialized cache key/,
// );
// });
// it("builds cache paths under the current home directory", () => {
// const cache = new Cache({
// run: vi.fn(),
// } as never);
// expect(cache.path).toBe(path.join(os.homedir(), "custom-cache"));
// });
// it("hashes cache keys without appending the default x86_64 architecture", async () => {
// const commandRunner = {
// run: vi.fn().mockResolvedValue({ stdout: "x86_64\n" }),
// };
// const cache = new Cache("cache-apt-pkgs", commandRunner as never);
// const expectedHash = crypto
// .createHash("md5")
// .update("curl=1 git=2 @ 'v1' 4")
// .digest("hex");
// await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
// `cache-apt-pkgs_${expectedHash}`,
// );
// });
// it("hashes cache keys with non-default architectures", async () => {
// const commandRunner = {
// run: vi.fn().mockResolvedValue({ stdout: "arm64\n" }),
// };
// const cache = new Cache("cache-apt-pkgs", commandRunner as never);
// const expectedHash = crypto
// .createHash("md5")
// .update("curl=1 git=2 @ 'v1' 4 arm64")
// .digest("hex");
// await expect(cache.key(["curl=1", "git=2"], "v1")).resolves.toBe(
// `cache-apt-pkgs_${expectedHash}`,
// );
// });
});

View file

@ -0,0 +1,75 @@
import { Artifacts, Instruments } from "../src/instrumentation.ts";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("instrumentation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const ARTIFACT_FILENAMES = [
"capa_app.log",
"capa_exec.log",
"cache_key.json",
"manifest_main.json",
"manifest_all.json",
];
describe("Artifacts", () => {
it("constructor initializes properties correctly", () => {
const dir = "/path/to/artifacts";
const debug = true;
const runId = "test-run-id";
const artifacts = new Artifacts(dir, ARTIFACT_FILENAMES, debug, runId);
expect(artifacts).toEqual({
dir: dir,
artifactFilenames: ARTIFACT_FILENAMES,
debug: debug,
runId: runId,
});
});
it("constructor generates a random runId when not provided", () => {
const dir = "/path/to/artifacts";
const debug = false;
const artifacts = new Artifacts(dir, ARTIFACT_FILENAMES, debug);
expect(artifacts.runId).toMatch(/^ghrunid-notfound-/);
});
});
// describe("Instruments", () => {
// it("constructor initializes properties correctly", () => {
// const cacheDir = "/path/to/cache";
// const debug = true;
// const artifacts = new Artifacts(
// "/path/to/cache",
// ARTIFACT_FILENAMES,
// debug,
// );
// const instruments = new Instruments(cacheDir, debug, artifacts);
// expect(instruments).toEqual({
// cacheDir: cacheDir,
// debug: debug,
// artifacts: artifacts,
// appLogger: expect.toBeTypeOf("object"),
// execLogger: expect.toBeTypeOf("object"),
// });
// });
// it("constructor initializes artifacts when not provided", () => {
// const cacheDir = "/path/to/cache";
// const debug = false;
// const instruments = new Instruments(cacheDir, debug);
// expect(instruments).toEqual({
// cacheDir: cacheDir,
// debug: debug,
// artifacts: expect.toBeTypeOf("object"),
// appLogger: expect.toBeTypeOf("object"),
// execLogger: expect.toBeTypeOf("object"),
// });
// });
// });
});

View file

@ -1,93 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Cache, CacheKey, isAptListsFresh } from "../src/cache.ts";
import { ActionPackageName } from "../src/action.ts";
describe("io", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("detects fresh apt lists when a file exists within search depth", () => {
vi.spyOn(fs, "statSync").mockImplementation(
(currentPath: fs.PathLike) =>
({
isDirectory: () =>
String(currentPath) !== "/var/lib/apt/lists/partial/pkg.idx",
}) as fs.Stats,
);
vi.spyOn(fs, "readdirSync")
.mockImplementationOnce(() => ["partial"] as never)
.mockImplementationOnce(() => ["pkg.idx"] as never);
expect(isAptListsFresh()).toBe(true);
});
it("returns a boolean for the current system apt list state", () => {
expect(typeof isAptListsFresh()).toBe("boolean");
});
it("serializes package values", () => {
expect(new ActionPackageName("git", "1.2.3").serialize()).toBe("git@1.2.3");
});
it("serializes and deserializes cache keys", () => {
const key = new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]);
expect(key.serialize()).toBe("v1 | 4 | arm64 | curl=1,git=2");
expect(CacheKey.deserialize(key.serialize())).toEqual(
new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]),
);
});
it("rejects invalid serialized cache keys", () => {
expect(() => CacheKey.deserialize("invalid")).toThrow(
/Invalid serialized cache key/,
);
});
it("builds cache paths under the current home directory", () => {
const cache = new Cache("custom-cache", {
run: vi.fn(),
} as never);
expect(cache.path).toBe(path.join(os.homedir(), "custom-cache"));
});
it("hashes cache keys without appending the default x86_64 architecture", async () => {
const commandRunner = {
run: vi.fn().mockResolvedValue({ stdout: "x86_64\n" }),
};
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
const expectedHash = crypto
.createHash("md5")
.update("curl=1 git=2 @ 'v1' 4")
.digest("hex");
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
`cache-apt-pkgs_${expectedHash}`,
);
});
it("hashes cache keys with non-default architectures", async () => {
const commandRunner = {
run: vi.fn().mockResolvedValue({ stdout: "arm64\n" }),
};
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
const expectedHash = crypto
.createHash("md5")
.update("curl=1 git=2 @ 'v1' 4 arm64")
.digest("hex");
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
`cache-apt-pkgs_${expectedHash}`,
);
});
});

View file

@ -3,46 +3,57 @@ import path from "node:path";
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import { Manifest, ManifestEntry } from "../src/manifest.js";
import { CacheKey } from "../src/cache.js";
import { createPackageName } from "ts-apt";
import { ActionPackageNames } from "../src/packages.js";
describe("manifest", () => {
it("serializes and deserializes manifest entries", () => {
const manifest = new Manifest(
[
new ManifestEntry("z", "2", undefined, ["/b", "/a"]),
new ManifestEntry("a", "1", undefined, []),
],
"input",
"cache-key",
const cacheKey = new CacheKey(
"v1",
"4",
"x86_64",
"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",
]),
],
cacheKey,
);
const parsed = Manifest.deserialize(manifest.serialize());
expect(parsed.entries[0]?.name).toBe("z");
expect(parsed.cacheKey).toBe("cache-key");
const parsed = Manifest.fromJSON(JSON.parse(JSON.stringify(manifest)));
expect(parsed.entries).toHaveLength(1);
expect(parsed.entries[0]?.packageName.serialize()).toBe("curl:amd64=8.1.0");
expect(parsed.entries[0]?.filepaths).toEqual(["/a", "/b"]);
});
it("writes and reads manifest files", () => {
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 manifest = new Manifest(
[new ManifestEntry("curl", "8.1", undefined, ["usr/bin/curl"])],
"input",
"cache-key",
const cacheKey = new CacheKey(
"v1",
"4",
"x86_64",
"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",
]),
],
cacheKey,
);
manifest.writeToFile(filePath);
const parsed = Manifest.readFromFile(filePath);
await manifest.writeToFile(filePath);
const parsed = await Manifest.readFromFile(filePath);
expect(parsed.entries).toHaveLength(1);
expect(parsed.entries[0]?.name).toBe("curl");
});
it("throws for missing files", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-missing-"));
expect(() =>
Manifest.readFromFile(path.join(tempDir, "none.json")),
).toThrow(/Manifest file not found/);
expect(parsed.entries[0]?.packageName.serialize()).toBe("curl:amd64=8.1.0");
});
});

174
test/packages.test.ts Normal file
View file

@ -0,0 +1,174 @@
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 { describe, expect, it, vi } from "vitest";
const PACKAGE_NAME = createPackageName("test-package", "1.0.0", "amd64");
function makeTempRoot(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function ensureScriptsDir(root: string): string {
const scriptsDir = path.join(root, "var", "lib", "dpkg", "info");
fs.mkdirSync(scriptsDir, { recursive: true });
return scriptsDir;
}
describe("packages", () => {
it("buildFileList returns an array of package files", async () => {
const root = makeTempRoot("pkg-files-");
const scriptsDir = ensureScriptsDir(root);
const packageFiles = [
path.join(scriptsDir, "test-package.list"),
path.join(scriptsDir, "test-package.md5sums"),
path.join(scriptsDir, "test-package.postinst"),
path.join(scriptsDir, "test-package.preinst"),
];
for (const filePath of packageFiles) {
fs.writeFileSync(filePath, "", "utf8");
}
const manager = {
listInstalledFiles: vi.fn().mockResolvedValue(packageFiles),
};
const result = await buildFileList(PACKAGE_NAME, manager as any, root);
expect(result).toEqual(packageFiles.map((item) => item.slice(1)).sort());
});
it("buildFileList returns an empty array when no files are found", async () => {
const root = makeTempRoot("pkg-empty-");
const manager = {
listInstalledFiles: vi.fn().mockResolvedValue([]),
};
const result = await buildFileList(PACKAGE_NAME, manager as any, root);
expect(result).toEqual([]);
});
it("findInstallScript returns undefined when scripts directory does not exist", async () => {
const missingRoot = makeTempRoot("pkg-missing-");
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",
);
const result = await findInstallScript(PACKAGE_NAME, "preinst", root);
expect(result).toBeUndefined();
});
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.preinst"), "", "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"));
});
it("updateAptLists calls the package manager's update method", async () => {
const aptListsPath = makeTempRoot("apt-lists-");
const mockUpdate = vi.fn().mockResolvedValue(undefined);
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
};
const packageManager = {
update: mockUpdate,
};
await updateAptLists(
packageManager as any,
mockLogger as any,
aptListsPath,
);
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
describe("ActionPackageNames", () => {
it("fromInput parses APT serialized input", () => {
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
const result = ActionPackageNames.fromInput(input);
expect(result.toArray()).toEqual([
createPackageName("other-package", "2.0.0", "i386"),
createPackageName("test-package", "1.0.0", "amd64"),
]);
});
it("fromInput returns an empty list when input is empty", () => {
const result = ActionPackageNames.fromInput("");
expect(result.toArray()).toEqual([]);
});
it("fromInput returns an empty list when input is whitespace", () => {
const result = ActionPackageNames.fromInput(" ");
expect(result.toArray()).toEqual([]);
});
it("fromJSON parses an array of serialized package names", () => {
const json = [
{ name: "test-package", version: "1.0.0", arch: "amd64" },
{ name: "other-package", version: "2.0.0", arch: "i386" },
];
const result = ActionPackageNames.fromJSON(json);
expect(result.toArray()).toEqual([
createPackageName("other-package", "2.0.0", "i386"),
createPackageName("test-package", "1.0.0", "amd64"),
]);
});
it("fromJSON returns an empty list when input is empty", () => {
const result = ActionPackageNames.fromJSON([]);
expect(result.toArray()).toEqual([]);
});
it("length returns the number of package names", () => {
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
const result = ActionPackageNames.fromInput(input);
expect(result.length).toBe(2);
});
it("toArray returns a sorted array of package names", () => {
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
const result = ActionPackageNames.fromInput(input);
expect(result.toArray()).toEqual([
createPackageName("other-package", "2.0.0", "i386"),
createPackageName("test-package", "1.0.0", "amd64"),
]);
});
});
});

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "nodenext",
"moduleResolution": "nodenext",
"paths": {
"ts-apt": [
"./node_modules/ts-apt/src/index.ts"

View file

@ -12,8 +12,7 @@
],
"compilerOptions": {
"types": [
"node",
"vitest/globals"
"node"
],
"allowImportingTsExtensions": true,
"noEmit": true

View file

@ -13,8 +13,6 @@
"types": [
"node"
],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
}