chore: add local release and dependency guards

This commit is contained in:
Mario Zechner
2026-05-20 13:06:28 +02:00
Unverified
parent 2e02c74dcb
commit ea4eab15cf
4 changed files with 328 additions and 1 deletions
+4 -1
View File
@@ -12,8 +12,10 @@
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present",
@@ -24,6 +26,7 @@
"prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && npm publish -ws --access public",
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
"release:local": "node scripts/local-release.mjs",
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"];
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const ignoredDirectories = new Set([".git", "dist", "node_modules"]);
const packageJsonFiles = [];
function collectPackageJsonFiles(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!ignoredDirectories.has(entry.name)) {
collectPackageJsonFiles(join(directory, entry.name));
}
continue;
}
if (entry.isFile() && entry.name === "package.json") {
packageJsonFiles.push(join(directory, entry.name));
}
}
}
function isInternalWorkspaceDependency(name) {
return name.startsWith("@earendil-works/pi-");
}
function isNonRegistrySpecifier(specifier) {
return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier);
}
function getVersionSpecifier(specifier) {
if (!specifier.startsWith("npm:")) return specifier;
const aliasTarget = specifier.slice("npm:".length);
const versionSeparator = aliasTarget.lastIndexOf("@");
if (versionSeparator <= 0) return specifier;
return aliasTarget.slice(versionSeparator + 1);
}
const failures = [];
collectPackageJsonFiles(".");
for (const file of packageJsonFiles.sort()) {
const packageJson = JSON.parse(readFileSync(file, "utf8"));
for (const section of dependencySections) {
const dependencies = packageJson[section];
if (!dependencies) continue;
for (const [name, specifier] of Object.entries(dependencies)) {
if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue;
if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue;
failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`);
}
}
}
if (failures.length > 0) {
console.error("Direct external dependencies must use exact versions:");
for (const failure of failures) console.error(` ${failure}`);
process.exit(1);
}
+74
View File
@@ -0,0 +1,74 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]);
const files = [];
function collectTypescriptFiles(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!ignoredDirectories.has(entry.name)) {
collectTypescriptFiles(join(directory, entry.name));
}
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
files.push(join(directory, entry.name));
}
}
}
function isRelativeJavaScriptSpecifier(specifier) {
return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier);
}
function getImportTypeSpecifier(node) {
if (!ts.isLiteralTypeNode(node.argument)) return undefined;
if (!ts.isStringLiteralLike(node.argument.literal)) return undefined;
return node.argument.literal;
}
const failures = [];
collectTypescriptFiles(".");
for (const file of files.sort()) {
const sourceText = readFileSync(file, "utf8");
const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true);
function checkSpecifier(node) {
if (!isRelativeJavaScriptSpecifier(node.text)) return;
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`);
}
function visit(node) {
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
checkSpecifier(node.moduleSpecifier);
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
checkSpecifier(node.moduleSpecifier);
} else if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments[0] &&
ts.isStringLiteralLike(node.arguments[0])
) {
checkSpecifier(node.arguments[0]);
} else if (ts.isImportTypeNode(node)) {
const specifier = getImportTypeSpecifier(node);
if (specifier) checkSpecifier(specifier);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
if (failures.length > 0) {
console.error("Relative .js imports are not allowed in non-declaration .ts files:");
for (const failure of failures) console.error(` ${failure}`);
process.exit(1);
}
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
const packages = [
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
];
function printUsage() {
console.log(`Usage: node scripts/local-release.mjs [options]
Builds and packs the publishable packages, then installs the tarballs into an
isolated directory outside the repository for local release testing.
Options:
--out <dir> Output directory. Defaults to a new directory under ${tmpdir()}
--force Remove --out first if it already exists
--skip-check Do not run npm run check before building
--skip-install Only create tarballs; do not create the isolated install
--help Show this help
`);
}
function parseArgs() {
const options = { force: false, outDir: undefined, skipCheck: false, skipInstall: false };
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help") {
printUsage();
process.exit(0);
}
if (arg === "--force") {
options.force = true;
continue;
}
if (arg === "--skip-check") {
options.skipCheck = true;
continue;
}
if (arg === "--skip-install") {
options.skipInstall = true;
continue;
}
if (arg === "--out") {
const value = args[++i];
if (!value) {
throw new Error("--out requires a directory");
}
options.outDir = value;
continue;
}
throw new Error(`Unknown option: ${arg}`);
}
return options;
}
function run(command, args, options = {}) {
console.log(`$ ${[command, ...args].join(" ")}`);
const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: "utf8",
stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit",
});
if (result.status !== 0) {
throw new Error(`Command failed: ${[command, ...args].join(" ")}`);
}
return result.stdout ?? "";
}
function readPackageJson(directory) {
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
}
function isInsidePath(child, parent) {
const relativePath = relative(parent, child);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function prepareOutputDirectory(options, repoRoot) {
if (!options.outDir) {
return mkdtempSync(join(tmpdir(), "pi-local-release-"));
}
const outDir = resolve(options.outDir);
if (isInsidePath(outDir, repoRoot)) {
throw new Error(`Output directory must be outside the repository: ${outDir}`);
}
if (existsSync(outDir)) {
if (!options.force) {
throw new Error(`Output directory already exists. Use --force to replace it: ${outDir}`);
}
rmSync(outDir, { force: true, recursive: true });
}
mkdirSync(outDir, { recursive: true });
return outDir;
}
function fileSpecifier(fromDirectory, file) {
const relativePath = relative(fromDirectory, file).replaceAll("\\", "/");
return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`;
}
function packPackage(pkg, tarballDirectory) {
const packageJson = readPackageJson(pkg.directory);
if (packageJson.name !== pkg.name) {
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
}
const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], {
capture: true,
cwd: pkg.directory,
});
const packed = JSON.parse(output)[0];
return join(tarballDirectory, packed.filename);
}
const options = parseArgs();
const repoRoot = process.cwd();
const rootPackageJson = readPackageJson(repoRoot);
if (rootPackageJson.name !== "pi-monorepo") {
throw new Error("Run this script from the repository root");
}
const outDir = prepareOutputDirectory(options, repoRoot);
const tarballDirectory = join(outDir, "tarballs");
const installDirectory = join(outDir, "install");
const binDirectory = join(outDir, "bin");
mkdirSync(tarballDirectory, { recursive: true });
if (!options.skipCheck) {
run("npm", ["run", "check"], { cwd: repoRoot });
}
for (const pkg of packages) {
run("npm", ["run", "clean"], { cwd: pkg.directory });
run("npm", ["run", "build"], { cwd: pkg.directory });
}
const tarballs = new Map();
for (const pkg of packages) {
const tarball = packPackage(pkg, tarballDirectory);
tarballs.set(pkg.name, tarball);
}
if (!options.skipInstall) {
mkdirSync(installDirectory, { recursive: true });
const dependencies = Object.fromEntries(
packages.map((pkg) => [pkg.name, fileSpecifier(installDirectory, tarballs.get(pkg.name))]),
);
writeFileSync(
join(installDirectory, "package.json"),
`${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`,
);
run("npm", ["install", "--omit=dev"], { cwd: installDirectory });
mkdirSync(binDirectory, { recursive: true });
symlinkSync(join(installDirectory, "node_modules", ".bin", "pi"), join(binDirectory, "pi"));
}
console.log("\nLocal release artifacts created:");
console.log(` ${outDir}`);
console.log("\nTarballs:");
for (const tarball of tarballs.values()) {
console.log(` ${tarball}`);
}
if (!options.skipInstall) {
console.log("\nIsolated install:");
console.log(` ${installDirectory}`);
console.log("\nRun the locally packed CLI from outside the repository:");
console.log(` ${join(binDirectory, "pi")} --help`);
}