Merge branch 'main-2' into build/bump-ts-apt-v1.1.0-rc.1

This commit is contained in:
Andrew Walsh 2026-11-08 14:02:24 -08:00 committed by GitHub
commit eeb6a4d9b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1427 additions and 2818 deletions

View file

@ -1,6 +1,8 @@
#!/usr/bin/env bash
# Blocks pushes while ts-apt is locally linked or not pinned to a commit SHA.
# Installed by the npm "prepare" script via core.hooksPath.
# Blocks pushes while a pinned dependency (e.g. ts-apt) is locally linked or
# not pinned to a commit SHA. Installed by the npm "prepare" script via
# core.hooksPath.
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/ops/check_tsapt_pin.sh"
cd "$(git rev-parse --show-toplevel)"
exec npm run -s check:deps-pin

View file

@ -125,9 +125,9 @@ jobs:
run: npm run check:latest-action-pins
# Fails if ts-apt is linked locally or not pinned to a pushed commit SHA.
- name: Check ts-apt Pin
- name: Check Dependency Pins
shell: bash
run: npm run check:tsapt-pin
run: npm run check:deps-pin
testing:
runs-on: ubuntu-latest

View file

@ -86,7 +86,7 @@ You are an expert developer focused on TypeScript, Bash, DevOps, Git, and GitHub
- `package.json` pins ts-apt to a commit SHA; local ts-apt changes are not visible to CI.
- MUST push the ts-apt change first, then bump the ts-apt SHA pin in the same change that uses it.
- NEVER push action code that depends on an unpushed ts-apt change.
- `npm run check:tsapt-pin` enforces the pin; it runs in CI and in the `.githooks/pre-push` hook installed by `npm install`.
- `npm run check:deps-pin` enforces the pin; it runs in CI and in the `.githooks/pre-push` hook installed by `npm install`.
- NEVER bypass the pre-push hook with `--no-verify`; unlink ts-apt instead.
## Quick Commands

53
dist/action.d.ts vendored
View file

@ -1,53 +0,0 @@
import { type PackageManager, type CommandRunner } from "../../ts-apt/dist/index.js";
import winston from "winston";
type TarModule = typeof import("tar");
type EmptyPackageBehavior = "error" | "warn" | "ignore";
interface PackageName {
readonly name: string;
readonly version?: string;
readonly distro?: string;
serialize(): string;
}
export interface ActionInputs {
readonly packages: string;
readonly version: string;
readonly executeInstallScripts: boolean;
readonly emptyPackagesBehavior: EmptyPackageBehavior;
readonly debug: boolean;
}
export interface ActionOutputs {
readonly cacheHit: boolean;
readonly packageVersionList: string;
readonly allPackageVersionList: string;
}
export declare class ActionPackageName implements PackageName {
readonly name: string;
readonly version?: string | undefined;
constructor(name: string, version?: string | undefined);
serialize(): string;
}
export declare function parseBoolean(value: string, fieldName: string): boolean;
export declare function normalizeInputPackages(inputPackages: string): string[];
export declare class ActionRunner {
private readonly commandRunner;
private readonly tar;
private readonly logger;
constructor(commandRunner: CommandRunner, tarModule: TarModule, logger: winston.Logger);
parseBoolean(value: string, fieldName: string): boolean;
normalizeInputPackages(inputPackages: string): string[];
resolvePackageVersion(packageManager: PackageManager, packageName: string): Promise<string>;
normalizePackagesWithVersions(packageManager: PackageManager, inputPackages: string): Promise<string[]>;
validateEmptyPackages(behavior: EmptyPackageBehavior, packages: string[]): void;
getCacheRoot(): string;
getCacheKey(normalizedPackages: string[], version: string): Promise<string>;
findInstallScript(packageName: string, extension: "preinst" | "postinst", root: string): string | undefined;
tarRelativePath(filePath: string): string;
buildFileListForPackage(packageManager: PackageManager, packageName: string): Promise<string[]>;
updateAptLists(packageManager: PackageManager): Promise<void>;
packageSpecifierToName(packageSpecifier: string): string;
installAndCachePackages(cacheDir: string, packages: string[], packageManager: PackageManager): Promise<void>;
restorePackages(cacheDir: string, executeInstallScripts: boolean): Promise<void>;
runAction(inputs: ActionInputs): Promise<ActionOutputs>;
}
export declare function runAction(inputs: ActionInputs, logger: winston.Logger): Promise<ActionOutputs>;
export {};

265
dist/action.js vendored
View file

@ -1,265 +0,0 @@
import * as cache from "@actions/cache";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createPackageManager, DefaultCommandRunner, } from "../../ts-apt/dist/index.js";
import { isAptListsFresh } from "./io.js";
import { readManifestAsCsv, writeManifest } from "./manifest.js";
import * as tar from "tar";
const FORCE_UPDATE_INCREMENT = "3";
const CACHE_DIRNAME = "cache-apt-pkgs";
const CACHE_PREFIX = "cache-apt-pkgs_";
export class ActionPackageName {
name;
version;
constructor(name, version) {
this.name = name;
this.version = version;
}
serialize() {
return this.version ? `${this.name}=${this.version}` : this.name;
}
}
function toPackageName(packageSpecifier) {
const [name, version] = packageSpecifier.split("=");
if (!name) {
throw new Error("Package name cannot be empty.");
}
return new ActionPackageName(name, version);
}
export function parseBoolean(value, fieldName) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`${fieldName} value '${value}' must be either true or false.`);
}
export function normalizeInputPackages(inputPackages) {
return inputPackages
.replace(/[,\\]/g, " ")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((part) => part.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
}
export class ActionRunner {
commandRunner;
tar;
logger;
constructor(commandRunner, tarModule, logger) {
this.commandRunner = commandRunner;
this.tar = tarModule;
this.logger = logger;
}
parseBoolean(value, fieldName) {
return parseBoolean(value, fieldName);
}
normalizeInputPackages(inputPackages) {
return normalizeInputPackages(inputPackages);
}
async resolvePackageVersion(packageManager, packageName) {
const packageInfo = await packageManager.getPackageInfo([
toPackageName(packageName),
]);
const version = packageInfo[0]?.version;
if (!version) {
throw new Error(`Unable to resolve package version for '${packageName}'.`);
}
return version;
}
async normalizePackagesWithVersions(packageManager, inputPackages) {
const raw = this.normalizeInputPackages(inputPackages);
const packages = await Promise.all(raw.map(async (pkg) => {
if (pkg.includes("=")) {
return pkg;
}
return `${pkg}=${await this.resolvePackageVersion(packageManager, pkg)}`;
}));
return packages.sort((a, b) => a.localeCompare(b));
}
validateEmptyPackages(behavior, packages) {
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.");
}
getCacheRoot() {
return path.join(os.homedir(), CACHE_DIRNAME);
}
async getCacheKey(normalizedPackages, version) {
const architecture = (await this.commandRunner.run("arch")).stdout.trim();
let value = `${normalizedPackages.join(" ")} @ ${version} ${FORCE_UPDATE_INCREMENT}`;
if (architecture !== "x86_64") {
value = `${value} ${architecture}`;
}
const hash = crypto.createHash("md5").update(value).digest("hex");
return `${CACHE_PREFIX}${hash}`;
}
findInstallScript(packageName, extension, root) {
const scriptsDir = path.join(root, "var", "lib", "dpkg", "info");
if (!fs.existsSync(scriptsDir)) {
return undefined;
}
const pattern = new RegExp(`^${packageName}(:.*)?\\.${extension}$`);
const matches = fs
.readdirSync(scriptsDir)
.filter((entry) => pattern.test(entry))
.sort((a, b) => a.localeCompare(b));
const candidate = matches[0];
if (!candidate) {
return undefined;
}
return path.join(scriptsDir, candidate);
}
tarRelativePath(filePath) {
return filePath.startsWith("/") ? filePath.slice(1) : filePath;
}
async buildFileListForPackage(packageManager, packageName) {
const files = (await packageManager.listInstalledFiles(toPackageName(packageName)))
.filter((filePath) => {
if (!fs.existsSync(filePath)) {
return false;
}
const stat = fs.lstatSync(filePath);
return stat.isFile() || stat.isSymbolicLink();
})
.map(this.tarRelativePath);
const preinst = this.findInstallScript(packageName, "preinst", "/");
const postinst = this.findInstallScript(packageName, "postinst", "/");
if (preinst) {
files.push(this.tarRelativePath(preinst));
}
if (postinst) {
files.push(this.tarRelativePath(postinst));
}
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
}
async updateAptLists(packageManager) {
if (isAptListsFresh()) {
return;
}
await packageManager.update();
}
packageSpecifierToName(packageSpecifier) {
return packageSpecifier.split("=")[0] ?? packageSpecifier;
}
async installAndCachePackages(cacheDir, packages, packageManager) {
await this.updateAptLists(packageManager);
writeManifest(path.join(cacheDir, "manifest_main.log"), packages);
const installedPackages = await packageManager.install(packages.map(toPackageName));
const manifestAll = [];
for (const pkg of installedPackages) {
const packageName = pkg.name;
const packageVersion = pkg.version;
if (!packageName || !packageVersion) {
continue;
}
const archivePath = path.join(cacheDir, `${packageName}=${packageVersion}.tar`);
if (!fs.existsSync(archivePath)) {
const filesToArchive = await this.buildFileListForPackage(packageManager, packageName);
await this.tar.create({
cwd: "/",
file: archivePath,
portable: false,
preservePaths: false,
follow: false,
noDirRecurse: false,
}, filesToArchive);
}
manifestAll.push(`${packageName}=${packageVersion}`);
}
writeManifest(path.join(cacheDir, "manifest_all.log"), manifestAll);
}
async restorePackages(cacheDir, executeInstallScripts) {
const archives = fs
.readdirSync(cacheDir)
.filter((entry) => entry.endsWith(".tar"))
.sort((a, b) => a.localeCompare(b));
for (const archive of archives) {
const archivePath = path.join(cacheDir, archive);
await this.tar.extract({
cwd: "/",
file: archivePath,
preservePaths: true,
});
if (!executeInstallScripts) {
continue;
}
const packageName = archive.split("=")[0] ?? "";
if (!packageName) {
continue;
}
const preinst = this.findInstallScript(packageName, "preinst", "/");
if (preinst) {
this.logger.info(`Running pre-install script for ${packageName}`);
await this.commandRunner.run("sudo", ["sh", "-x", preinst, "install"]);
}
const postinst = this.findInstallScript(packageName, "postinst", "/");
if (postinst) {
this.logger.info(`Running post-install script for ${packageName}`);
await this.commandRunner.run("sudo", [
"sh",
"-x",
postinst,
"configure",
]);
}
}
}
async runAction(inputs) {
if (/\s/.test(inputs.version)) {
throw new Error(`Version value '${inputs.version}' cannot contain spaces.`);
}
const packageInfoManager = await createPackageManager(false);
const normalizedPackages = await this.normalizePackagesWithVersions(packageInfoManager, inputs.packages);
this.validateEmptyPackages(inputs.emptyPackagesBehavior, normalizedPackages);
const cacheDir = this.getCacheRoot();
fs.mkdirSync(cacheDir, { recursive: true });
if (normalizedPackages.length === 0) {
writeManifest(path.join(cacheDir, "manifest_main.log"), []);
writeManifest(path.join(cacheDir, "manifest_all.log"), []);
return {
cacheHit: false,
packageVersionList: "",
allPackageVersionList: "",
};
}
const key = await this.getCacheKey(normalizedPackages, inputs.version);
fs.writeFileSync(path.join(cacheDir, "cache_key.md5"), key.replace(CACHE_PREFIX, ""), "utf8");
const restoredKey = await cache.restoreCache([cacheDir], key);
const cacheHit = restoredKey === key;
if (cacheHit) {
await this.restorePackages(cacheDir, inputs.executeInstallScripts);
}
else {
const installManager = await createPackageManager(true);
const installTargets = normalizedPackages.map((packageSpecifier) => this.packageSpecifierToName(packageSpecifier));
await this.installAndCachePackages(cacheDir, installTargets, installManager);
await cache.saveCache([cacheDir], key);
}
return {
cacheHit,
packageVersionList: readManifestAsCsv(path.join(cacheDir, "manifest_main.log")),
allPackageVersionList: readManifestAsCsv(path.join(cacheDir, "manifest_all.log")),
};
}
}
export async function runAction(inputs, logger) {
const packageManager = (await createPackageManager(true, logger, logger));
const commandRunner = new DefaultCommandRunner(logger, logger);
const actionRunner = new ActionRunner(commandRunner, tar, logger);
return await actionRunner.runAction(inputs);
}
//# sourceMappingURL=action.js.map

1
dist/action.js.map vendored

File diff suppressed because one or more lines are too long

1
dist/index.d.ts vendored
View file

@ -1 +0,0 @@
export {};

41
dist/index.js vendored
View file

@ -1,41 +0,0 @@
import * as core from "@actions/core";
import { parseBoolean, runAction } from "./action.js";
import winston from "winston";
function parseEmptyPackagesBehavior(value) {
if (value === "error" || value === "warn" || value === "ignore") {
return value;
}
throw new Error(`empty_packages_behavior value '${value}' must be one of: error, warn, ignore.`);
}
function getInputs() {
const executeInstallScriptsRaw = core.getInput("execute_install_scripts");
const debugRaw = core.getInput("debug");
const emptyPackagesBehaviorRaw = core.getInput("empty_packages_behavior") || "error";
return {
packages: core.getInput("packages", { required: true }),
version: core.getInput("version"),
executeInstallScripts: parseBoolean(executeInstallScriptsRaw, "execute_install_scripts"),
emptyPackagesBehavior: parseEmptyPackagesBehavior(emptyPackagesBehaviorRaw),
debug: parseBoolean(debugRaw, "debug"),
};
}
async function main() {
try {
const inputs = getInputs();
const logger = winston.createLogger({
level: inputs.debug ? "debug" : "info",
format: winston.format.combine(winston.format.colorize(), winston.format.printf(({ level, message }) => `${level}: ${message}`)),
transports: [new winston.transports.Console()],
});
const outputs = await runAction(inputs, logger);
core.setOutput("cache-hit", String(outputs.cacheHit));
core.setOutput("package-version-list", outputs.packageVersionList);
core.setOutput("all-package-version-list", outputs.allPackageVersionList);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
}
}
void main();
//# sourceMappingURL=index.js.map

1
dist/index.js.map vendored
View file

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAqB,MAAM,aAAa,CAAC;AACzE,OAAO,OAAO,MAAM,SAAS,CAAC;AAE9B,SAAS,0BAA0B,CACjC,KAAa;IAEb,IAAI,KAAK,KAAK,OAAO,IAAI,KAAK,KAAK,MAAM,IAAI,KAAK,KAAK,QAAQ,EAAE,CAAC;QAChE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,IAAI,KAAK,CACb,kCAAkC,KAAK,wCAAwC,CAChF,CAAC;AACJ,CAAC;AAED,SAAS,SAAS;IAChB,MAAM,wBAAwB,GAAG,IAAI,CAAC,QAAQ,CAAC,yBAAyB,CAAC,CAAC;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IACxC,MAAM,wBAAwB,GAC5B,IAAI,CAAC,QAAQ,CAAC,yBAAyB,CAAC,IAAI,OAAO,CAAC;IAEtD,OAAO;QACL,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;QACvD,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC;QACjC,qBAAqB,EAAE,YAAY,CACjC,wBAAwB,EACxB,yBAAyB,CAC1B;QACD,qBAAqB,EAAE,0BAA0B,CAAC,wBAAwB,CAAC;QAC3E,KAAK,EAAE,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC;KACvC,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;QAC3B,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAClC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM;YACtC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAC5B,OAAO,CAAC,MAAM,CAAC,QAAQ,EAAE,EACzB,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,GAAG,KAAK,KAAK,OAAO,EAAE,CAAC,CACtE;YACD,UAAU,EAAE,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC;SAC/C,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAEhD,IAAI,CAAC,SAAS,CAAC,WAAW,EAAE,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;QACtD,IAAI,CAAC,SAAS,CAAC,sBAAsB,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAC;QACnE,IAAI,CAAC,SAAS,CAAC,0BAA0B,EAAE,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC5E,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACvE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAC1B,CAAC;AACH,CAAC;AAED,KAAK,IAAI,EAAE,CAAC"}

23
dist/io.d.ts vendored
View file

@ -1,23 +0,0 @@
import { type CommandRunner } from "../../ts-apt/dist/index.js";
export declare function isAptListsFresh(): boolean;
export declare class Package {
readonly name: string;
readonly version: string;
constructor(name: string, version: string);
serialize(): string;
}
export declare class CacheKey {
readonly version: string;
readonly forceUpdateIncrement: string;
readonly arch: string;
readonly normalizedPackages: string[];
constructor(version: string, forceUpdateIncrement: string, arch: string, normalizedPackages: string[]);
serialize(): string;
}
export declare function deserializeCacheKey(serialized: string): CacheKey;
export declare class Cache {
private readonly cachePath;
private readonly commandRunner;
constructor(cacheDir: string | undefined, commandRunner: CommandRunner);
getKey(normalizedPackages: string[], version: string): Promise<string>;
}

88
dist/io.js vendored
View file

@ -1,88 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import * as os from "os";
export function isAptListsFresh() {
const aptListsPath = "/var/lib/apt/lists";
const maxDepth = 5;
function search(currentPath, currentDepth) {
if (currentDepth > maxDepth)
return false;
try {
const stats = fs.statSync(currentPath);
if (stats.isDirectory()) {
const entries = fs.readdirSync(currentPath);
for (const entry of entries) {
const fullPath = path.join(currentPath, entry);
if (search(fullPath, currentDepth + 1)) {
return true;
}
}
}
else {
return true;
}
}
catch (error) {
// Ignore permission errors or inaccessible paths
}
return false;
}
return search(aptListsPath, 0);
}
export class Package {
name;
version;
constructor(name, version) {
this.name = name;
this.version = version;
}
serialize() {
return `${this.name}@${this.version}`;
}
}
const FORCE_UPDATE_INCREMENT = "4";
const CACHE_DIRNAME = "cache-apt-pkgs";
const CACHE_PREFIX = "cache-apt-pkgs_";
export class CacheKey {
version;
forceUpdateIncrement;
arch;
normalizedPackages;
constructor(version, forceUpdateIncrement, arch, normalizedPackages) {
this.version = version;
this.forceUpdateIncrement = forceUpdateIncrement;
this.arch = arch;
this.normalizedPackages = normalizedPackages;
}
serialize() {
return `${this.version}|${this.forceUpdateIncrement}|${this.arch}|${this.normalizedPackages.join(",")}`;
}
}
export function deserializeCacheKey(serialized) {
const parts = serialized.split("|");
if (parts.length !== 4) {
throw new Error(`Invalid serialized cache key: ${serialized}`);
}
const [version, forceUpdateIncrement, arch, normalizedPackagesStr] = parts;
const normalizedPackages = normalizedPackagesStr.split(",");
return new CacheKey(version, forceUpdateIncrement, arch, normalizedPackages);
}
export class Cache {
cachePath;
commandRunner;
constructor(cacheDir = CACHE_DIRNAME, commandRunner) {
this.cachePath = path.join(os.homedir(), cacheDir);
this.commandRunner = commandRunner;
}
async getKey(normalizedPackages, version) {
const architecture = await (await this.commandRunner.run("arch")).stdout.trim();
let value = `${normalizedPackages.join(" ")} @ ${version} ${FORCE_UPDATE_INCREMENT}`;
if (architecture !== "x86_64") {
value = `${value} ${architecture}`;
}
const hash = crypto.createHash("md5").update(value).digest("hex");
return `${CACHE_PREFIX}${hash}`;
}
}
//# sourceMappingURL=io.js.map

1
dist/io.js.map vendored
View file

@ -1 +0,0 @@
{"version":3,"file":"io.js","sourceRoot":"","sources":["../src/io.ts"],"names":[],"mappings":"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"}

2
dist/manifest.d.ts vendored
View file

@ -1,2 +0,0 @@
export declare function writeManifest(filePath: string, entries: string[]): void;
export declare function readManifestAsCsv(filePath: string): string;

17
dist/manifest.js vendored
View file

@ -1,17 +0,0 @@
import fs from "node:fs";
export function writeManifest(filePath, entries) {
const normalized = [...entries].filter(Boolean).sort((a, b) => a.localeCompare(b));
fs.writeFileSync(filePath, normalized.join("\n"), "utf8");
}
export function readManifestAsCsv(filePath) {
if (!fs.existsSync(filePath)) {
return "";
}
return fs
.readFileSync(filePath, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join(",");
}
//# sourceMappingURL=manifest.js.map

View file

@ -1 +0,0 @@
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAEzB,MAAM,UAAU,aAAa,CAAC,QAAgB,EAAE,OAAiB;IAC/D,MAAM,UAAU,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;IACnF,EAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;AAC5D,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7B,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,OAAO,EAAE;SACN,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC;SAC9B,KAAK,CAAC,OAAO,CAAC;SACd,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAC;AACf,CAAC"}

9
dist/shell.d.ts vendored
View file

@ -1,9 +0,0 @@
import { type SpawnSyncOptions } from "node:child_process";
export interface CommandResult {
readonly status: number;
readonly stdout: string;
readonly stderr: string;
}
export declare function runCommand(command: string, args?: string[], options?: SpawnSyncOptions): CommandResult;
export declare function runCommandOrThrow(command: string, args?: string[], options?: SpawnSyncOptions): CommandResult;
export declare function fileIsFresh(path: string, withinMinutes: number): boolean;

46
dist/shell.js vendored
View file

@ -1,46 +0,0 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
export function runCommand(command, args = [], options = {}) {
const result = spawnSync(command, args, {
encoding: "utf8",
...options,
});
const stdout = typeof result.stdout === "string"
? result.stdout
: result.stdout
? result.stdout.toString("utf8")
: "";
const stderr = typeof result.stderr === "string"
? result.stderr
: result.stderr
? result.stderr.toString("utf8")
: "";
return {
status: result.status ?? 1,
stdout,
stderr,
};
}
export function runCommandOrThrow(command, args = [], options = {}) {
const result = runCommand(command, args, options);
if (result.status !== 0) {
throw new Error([
`Command failed: ${command} ${args.join(" ")}`,
`exit: ${result.status}`,
result.stdout ? `stdout:\n${result.stdout.trim()}` : "",
result.stderr ? `stderr:\n${result.stderr.trim()}` : "",
]
.filter(Boolean)
.join("\n"));
}
return result;
}
export function fileIsFresh(path, withinMinutes) {
if (!fs.existsSync(path)) {
return false;
}
const stat = fs.statSync(path);
const maxAgeMs = withinMinutes * 60 * 1000;
return Date.now() - stat.mtimeMs <= maxAgeMs;
}
//# sourceMappingURL=shell.js.map

1
dist/shell.js.map vendored
View file

@ -1 +0,0 @@
{"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAyB,MAAM,oBAAoB,CAAC;AACtE,OAAO,EAAE,MAAM,SAAS,CAAC;AAQzB,MAAM,UAAU,UAAU,CACxB,OAAe,EACf,OAAiB,EAAE,EACnB,UAA4B,EAAE;IAE9B,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE;QACtC,QAAQ,EAAE,MAAM;QAChB,GAAG,OAAO;KACX,CAAC,CAAC;IAEH,MAAM,MAAM,GACV,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;QAC/B,CAAC,CAAC,MAAM,CAAC,MAAM;QACf,CAAC,CAAC,MAAM,CAAC,MAAM;YACb,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAChC,CAAC,CAAC,EAAE,CAAC;IACX,MAAM,MAAM,GACV,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ;QAC/B,CAAC,CAAC,MAAM,CAAC,MAAM;QACf,CAAC,CAAC,MAAM,CAAC,MAAM;YACb,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAChC,CAAC,CAAC,EAAE,CAAC;IAEX,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC;QAC1B,MAAM;QACN,MAAM;KACP,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,OAAe,EACf,OAAiB,EAAE,EACnB,UAA4B,EAAE;IAE9B,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;IAClD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CACb;YACE,mBAAmB,OAAO,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;YAC9C,SAAS,MAAM,CAAC,MAAM,EAAE;YACxB,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE;YACvD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE;SACxD;aACE,MAAM,CAAC,OAAO,CAAC;aACf,IAAI,CAAC,IAAI,CAAC,CACd,CAAC;IACJ,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,aAAqB;IAC7D,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/B,MAAM,QAAQ,GAAG,aAAa,GAAG,EAAE,GAAG,IAAI,CAAC;IAC3C,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,OAAO,IAAI,QAAQ,CAAC;AAC/C,CAAC"}

3445
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -55,8 +55,8 @@
"check:latest-action-pins": "node --experimental-strip-types ./scripts/ops/check_latest_action_pin.mts --scan",
"check:pr": "node --experimental-strip-types ./scripts/ops/pr_checks.mts",
"check:configs": "node --experimental-strip-types ./scripts/ops/check_configs.mts",
"check:tsapt-pin": "./scripts/ops/check_tsapt_pin.sh",
"check": "npm run -s runall -- check:latest-action-pins check:pr check:configs check:tsapt-pin",
"check:deps-pin": "node --experimental-strip-types ./scripts/ops/check_deps_pin.mts ts-apt",
"check": "npm run -s runall -- check:latest-action-pins check:pr check:configs check:deps-pin",
"dev:tsapt:link": "npm link ../ts-apt --no-save; echo \"NOTE: npm run dev:tsapt:unlink when you are finished.\"",
"dev:tsapt:unlink": "npm unlink ts-apt --no-save && npm ci",
"gh:auth": "gh auth status >/dev/null 2>&1 || gh auth login --web && gh auth setup-git",
@ -72,7 +72,6 @@
"repo:tags:upload": "npm run repo::sync -- upload --target tags",
"repo:all:download": "npm run repo::sync -- download --target all",
"repo:all:upload": "npm run repo::sync -- upload --target all",
"docs:api": "typedoc --options typedoc.json",
"release": "semantic-release",
"release:dry-run": "semantic-release --dry-run",
"lint": "eslint src test scripts --ext .ts,.mts",
@ -85,14 +84,10 @@
"@actions/artifact": "^6.2.1",
"@actions/cache": "^6.1.0",
"@actions/core": "^3.0.1",
"class-transformer": "^0.5.1",
"rollup": "^4.62.2",
"tar": "^7.4.3",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#67d450ee524f1a6145e5d30eaf5d3d6615e3d0ef",
"ts-apt": "git+https://github.com/awalsh128/ts-apt.git#342c7d0f453dcf1a69da83d7d195f9df2611a351",
"winston": "^3.19.0"
},
"devDependencies": {
"@devcontainers/cli": "^0.87.0",
"@robingenz/zli": "^0.2.0",
"@rollup/plugin-commonjs": "^29.0.3",
"@rollup/plugin-json": "^6.1.0",
@ -104,19 +99,27 @@
"@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",
"eslint": "^10.6.0",
"internet-message": "^1.0.0",
"npm-run-all": "^4.1.5",
"rollup": "^4.68.0",
"semantic-release": "^25.0.5",
"type-coverage": "^2.29.7",
"typedoc": "^0.28.19",
"typedoc-plugin-markdown": "^4.12.0",
"typescript": "^6.0.3",
"vitest": "^4.1.10",
"zod": "^4.4.3"
},
"overrides": {
"glob@>=9.0.0 <14.0.2": "^14.0.4",
"eslint": {
"minimatch": "^11.0.5"
},
"@typescript-eslint/parser": {
"minimatch": "^11.0.5"
},
"type-coverage": {
"minimatch": "^11.0.5"
}
}
}

View file

@ -0,0 +1,142 @@
#!/usr/bin/env -S node --experimental-strip-types
/**
* Fails when a dependency is not resolved from a pushed commit SHA pin.
*
* Guards against shipping code built or tested against a locally linked
* dependency (e.g. npm run dev:tsapt:link), which CI cannot see. For each
* dependency name given as an argument, checks that:
* 1. node_modules/<name> is not a symlink (skipped if it is absent).
* 2. package.json pins <name> to a full commit SHA (`...#<40 hex chars>`).
* 3. package-lock.json resolves <name> to that same SHA and not a link.
*
* Used by .githooks/pre-push and the CI action-deps-pinned job.
*
* Usage: check_deps_pin.mts <dependency> [<dependency>...]
*/
import fs from "node:fs";
import path from "node:path";
import {
fail,
logError,
logSuccess,
readJsonFile,
ROOT_DIR,
runMain,
usage,
} from "../devopslib.mts";
const SHA_PIN_PATTERN = /#([0-9a-f]{40})$/;
type PackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
type LockEntry = {
resolved?: string;
link?: boolean;
};
type PackageLock = {
packages?: Record<
string,
LockEntry & {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}
>;
};
function isSymlink(filePath: string): boolean {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
/** Returns one problem message per failed check for the dependency. */
function checkDependencyPin(
name: string,
packageJson: PackageJson,
packageLock: PackageLock,
): string[] {
const problems: string[] = [];
const modulePath = path.join(ROOT_DIR, "node_modules", name);
if (isSymlink(modulePath)) {
problems.push(
`node_modules/${name} is a symlink to ${fs.readlinkSync(modulePath)}; unlink it and run 'npm ci' first.`,
);
}
const spec =
packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name];
if (spec === undefined) {
problems.push(`package.json has no ${name} dependency.`);
return problems;
}
const sha = SHA_PIN_PATTERN.exec(spec)?.[1];
if (sha === undefined) {
problems.push(
`package.json ${name} '${spec}' is not pinned to a full commit SHA.`,
);
return problems;
}
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}'.`,
);
}
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}'.`,
);
} else if (!entry.resolved?.endsWith(`#${sha}`)) {
problems.push(
`package-lock.json ${name} resolved '${entry.resolved}' does not match pin ${sha}.`,
);
}
return problems;
}
function main(): void {
const names = process.argv.slice(2);
if (names.length === 0 || names.some((name) => name.length === 0)) {
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 problems = names.flatMap((name) =>
checkDependencyPin(name, packageJson, packageLock),
);
for (const problem of problems) {
logError(problem);
}
if (problems.length > 0) {
fail(`Dependency pin check failed with ${problems.length} error(s).`);
}
logSuccess(`Dependency pin check passed for ${names.join(", ")}.`);
}
await runMain(main, 1);

View file

@ -1,65 +0,0 @@
#!/usr/bin/env bash
# Fails when ts-apt is not resolved from a pushed commit SHA pin.
#
# Guards against shipping code built or tested against a local ts-apt link
# (npm run dev:tsapt:link), which CI cannot see. Checks that:
# 1. node_modules/ts-apt is not a symlink (skipped if node_modules is absent).
# 2. package.json pins ts-apt to a full commit SHA.
# 3. package-lock.json resolves ts-apt to that same SHA and not a link.
#
# Used by .githooks/pre-push and the CI action-deps-pinned job.
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
cd "${ROOT_DIR}"
errors=0
error() {
echo "ERROR: $*" >&2
errors=$((errors + 1))
}
if [[ -L node_modules/ts-apt ]]; then
error "node_modules/ts-apt is a symlink to $(readlink node_modules/ts-apt); run 'npm run dev:tsapt:unlink' first."
fi
# Prints one problem per line; package.json and package-lock.json are parsed
# with node to avoid a jq dependency.
lock_problems="$(node --input-type=module - <<'EOF'
import { readFileSync } from "node:fs";
const pin = /^git\+https:\/\/github\.com\/awalsh128\/ts-apt\.git#([0-9a-f]{40})$/;
const spec = JSON.parse(readFileSync("package.json", "utf8")).dependencies?.["ts-apt"];
const match = pin.exec(spec ?? "");
if (!match) {
console.log(`package.json ts-apt '${spec}' is not pinned to a full commit SHA.`);
process.exit(0);
}
const sha = match[1];
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
const rootSpec = lock.packages?.[""]?.dependencies?.["ts-apt"];
if (rootSpec !== spec) {
console.log(`package-lock.json root ts-apt '${rootSpec}' does not match package.json '${spec}'.`);
}
const entry = lock.packages?.["node_modules/ts-apt"];
if (!entry) {
console.log("package-lock.json has no node_modules/ts-apt entry.");
} else if (entry.link) {
console.log(`package-lock.json ts-apt is a link to '${entry.resolved}'.`);
} else if (!entry.resolved?.endsWith(`#${sha}`)) {
console.log(`package-lock.json ts-apt resolved '${entry.resolved}' does not match pin ${sha}.`);
}
EOF
)"
if [[ -n "${lock_problems}" ]]; then
while IFS= read -r problem; do
error "${problem}"
done <<<"${lock_problems}"
fi
if ((errors > 0)); then
echo "ts-apt pin check failed with ${errors} error(s)." >&2
exit 1
fi
echo "ts-apt pin check passed."