fix: pin internal dependencies to exact versions

Update release synchronization and dependency checks to enforce exact versions. Refresh both lockfiles and add regression coverage.

closes #10865
This commit is contained in:
Armin Ronacher 2026-10-13 14:27:38 +02:00
parent a3a2ee8ec2
commit f13c00fb3a
14 changed files with 129 additions and 50 deletions

View file

@ -42,7 +42,7 @@
## Dependency and Install Security
- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions.
- Treat npm dep and lockfile changes as reviewed code. Direct registry deps, including internal workspace deps, stay pinned to exact versions.
- When updating `undici`, you MUST read its changelog/release notes for the target version and evaluate whether any changes may affect functionality before applying the update.
- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks.
- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`.

28
package-lock.json generated
View file

@ -5033,7 +5033,7 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"typebox": "1.3.27"
},
"devDependencies": {
@ -5058,7 +5058,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^1.2.0",
"@earendil-works/pi-telemetry": "1.2.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@ -5176,11 +5176,11 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^1.2.0",
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/pi-mcp": "^1.2.0",
"@earendil-works/pi-tui": "^1.2.0",
"@earendil-works/pi-agent-core": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-codemode": "1.2.0",
"@earendil-works/pi-mcp": "1.2.0",
"@earendil-works/pi-tui": "1.2.0",
"@silvia-odwyer/photon-node": "0.3.4",
"brace-expansion": "5.0.12",
"chalk": "6.0.0",
@ -5300,9 +5300,9 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.2.0",
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/chord": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-codemode": "1.2.0",
"@silvia-odwyer/photon": "0.3.3",
"diff": "8.0.4",
"typebox": "1.3.27"
@ -5320,8 +5320,8 @@
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@earendil-works/chord": "^1.2.0",
"@earendil-works/pi-durable": "^1.2.0"
"@earendil-works/chord": "1.2.0",
"@earendil-works/pi-durable": "1.2.0"
},
"devDependencies": {
"shx": "0.3.4",
@ -5335,8 +5335,8 @@
"name": "@earendil-works/pi-evals",
"version": "1.2.0",
"devDependencies": {
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-coding-agent": "^1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-coding-agent": "1.2.0",
"@types/node": "22.19.19",
"@vitest-evals/core": "0.15.0",
"autoevals": "0.3.0",

View file

@ -23,7 +23,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"typebox": "1.3.27"
},
"keywords": [

View file

@ -69,7 +69,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^1.2.0",
"@earendil-works/pi-telemetry": "1.2.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865))
## [1.2.0] - 2026-10-12
### New Features

View file

@ -390,7 +390,7 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.2.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"typebox": "1.3.27"
},
"engines": {
@ -404,7 +404,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-telemetry": "^1.2.0",
"@earendil-works/pi-telemetry": "1.2.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
@ -496,11 +496,11 @@
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.2.0.tgz",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-agent-core": "^1.2.0",
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/pi-mcp": "^1.2.0",
"@earendil-works/pi-tui": "^1.2.0",
"@earendil-works/pi-agent-core": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-codemode": "1.2.0",
"@earendil-works/pi-mcp": "1.2.0",
"@earendil-works/pi-tui": "1.2.0",
"@silvia-odwyer/photon-node": "0.3.4",
"brace-expansion": "5.0.12",
"chalk": "6.0.0",

View file

@ -40,11 +40,11 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^1.2.0",
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/pi-mcp": "^1.2.0",
"@earendil-works/pi-tui": "^1.2.0",
"@earendil-works/pi-agent-core": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-codemode": "1.2.0",
"@earendil-works/pi-mcp": "1.2.0",
"@earendil-works/pi-tui": "1.2.0",
"@silvia-odwyer/photon-node": "0.3.4",
"brace-expansion": "5.0.12",
"chalk": "6.0.0",

View file

@ -143,9 +143,9 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^1.2.0",
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/chord": "1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-codemode": "1.2.0",
"@silvia-odwyer/photon": "0.3.3",
"diff": "8.0.4",
"typebox": "1.3.27"

View file

@ -45,8 +45,8 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/chord": "^1.2.0",
"@earendil-works/pi-durable": "^1.2.0"
"@earendil-works/chord": "1.2.0",
"@earendil-works/pi-durable": "1.2.0"
},
"devDependencies": {
"shx": "0.3.4",

View file

@ -11,8 +11,8 @@
"test": "vitest run --config vitest.test.config.ts"
},
"devDependencies": {
"@earendil-works/pi-ai": "^1.2.0",
"@earendil-works/pi-coding-agent": "^1.2.0",
"@earendil-works/pi-ai": "1.2.0",
"@earendil-works/pi-coding-agent": "1.2.0",
"@types/node": "22.19.19",
"@vitest-evals/core": "0.15.0",
"autoevals": "0.3.0",

View file

@ -4,7 +4,6 @@ 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 internalPackageNames = new Set(["@earendil-works/chord"]);
const packageJsonFiles = [];
function collectPackageJsonFiles(directory) {
@ -22,10 +21,6 @@ function collectPackageJsonFiles(directory) {
}
}
function isInternalWorkspaceDependency(name) {
return name.startsWith("@earendil-works/pi-") || internalPackageNames.has(name);
}
function isNonRegistrySpecifier(specifier) {
return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier);
}
@ -50,7 +45,7 @@ for (const file of packageJsonFiles.sort()) {
if (!dependencies) continue;
for (const [name, specifier] of Object.entries(dependencies)) {
if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue;
if (isNonRegistrySpecifier(specifier)) continue;
if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue;
failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`);
}
@ -58,7 +53,7 @@ for (const file of packageJsonFiles.sort()) {
}
if (failures.length > 0) {
console.error("Direct external dependencies must use exact versions:");
console.error("Direct registry dependencies must use exact versions:");
for (const failure of failures) console.error(` ${failure}`);
process.exit(1);
}

View file

@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const script = fileURLToPath(new URL("./check-pinned-deps.mjs", import.meta.url));
async function check(t, manifest) {
const root = await mkdtemp(join(tmpdir(), "pi-pinned-deps-"));
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(join(root, "package.json"), JSON.stringify(manifest));
return spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" });
}
// #10865: internal workspace dependencies must not allow incompatible newer releases.
test("rejects internal and external registry dependency ranges in every direct dependency section", async (t) => {
const result = await check(t, {
dependencies: {
"@earendil-works/pi-codemode": "^1.2.0",
"@earendil-works/chord": "~1.2.0",
external: "^1.0.0",
},
devDependencies: { "@earendil-works/pi-ai": "1.x" },
optionalDependencies: { "@earendil-works/pi-tui": "*" },
});
assert.equal(result.status, 1);
for (const dependency of [
"dependencies.@earendil-works/pi-codemode",
"dependencies.@earendil-works/chord",
"dependencies.external",
"devDependencies.@earendil-works/pi-ai",
"optionalDependencies.@earendil-works/pi-tui",
]) {
assert.ok(result.stderr.includes(`${dependency} must be pinned`), result.stderr);
}
});
test("accepts exact internal and external versions, exact registry aliases, and non-registry specifiers", async (t) => {
const result = await check(t, {
dependencies: {
"@earendil-works/pi-codemode": "1.2.0",
"@earendil-works/chord": "1.2.0",
external: "1.0.0",
alias: "npm:@earendil-works/pi-ai@1.1.0",
local: "file:../local",
workspace: "workspace:*",
},
devDependencies: { "@earendil-works/pi-ai": "1.2.0" },
optionalDependencies: { "@earendil-works/pi-tui": "1.2.0-rc.1" },
});
assert.equal(result.status, 0, result.stderr);
});
test("rejects registry alias ranges", async (t) => {
const result = await check(t, {
dependencies: { alias: "npm:@earendil-works/pi-ai@^1.2.0" },
});
assert.equal(result.status, 1);
assert.match(result.stderr, /dependencies\.alias must be pinned/);
});

View file

@ -2,7 +2,7 @@
/**
* Validates lockstep versions for published packages, then synchronizes
* internal dependency versions in all workspace packages, including private ones.
* exact internal dependency versions in all workspace packages, including private ones.
*/
import { readFileSync, writeFileSync } from "node:fs";
@ -41,7 +41,7 @@ console.log("\nAll non-private packages are at the same version (lockstep).");
let totalUpdates = 0;
const updatedPackages = new Set();
for (const pkg of workspacePackages) {
for (const dependencyType of ["dependencies", "devDependencies"]) {
for (const dependencyType of ["dependencies", "devDependencies", "optionalDependencies"]) {
const dependencies = pkg.data[dependencyType];
if (!dependencies) {
continue;
@ -50,15 +50,14 @@ for (const pkg of workspacePackages) {
for (const [dependencyName, currentSpecifier] of Object.entries(dependencies)) {
// Registry aliases such as `npm:@earendil-works/pi-ai@0.1.2` are never workspace-linked,
// so lockstep bumping them would point at a version that is not published yet.
const version = versionMap.get(dependencyName);
const newSpecifier = version ? `^${version}` : null;
const newSpecifier = versionMap.get(dependencyName);
if (!newSpecifier || currentSpecifier === newSpecifier) {
continue;
}
console.log(`\n${pkg.data.name}:`);
console.log(
` ${dependencyName}: ${currentSpecifier} → ${newSpecifier}${dependencyType === "devDependencies" ? " (devDependencies)" : ""}`,
` ${dependencyName}: ${currentSpecifier} → ${newSpecifier}${dependencyType === "dependencies" ? "" : ` (${dependencyType})`}`,
);
dependencies[dependencyName] = newSpecifier;
updatedPackages.add(pkg);

View file

@ -25,7 +25,8 @@ function runSyncVersions(root) {
});
}
test("synchronizes private dependencies without touching registry aliases, generated manifests, or published lockstep", async () => {
// #10865: caret ranges allowed older agents to install incompatible newer workspace dependencies.
test("pins published and private dependencies without touching registry aliases, generated manifests, or published lockstep", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-sync-versions-"));
try {
await writeManifest(root, "packages/ai", {
@ -35,6 +36,15 @@ test("synchronizes private dependencies without touching registry aliases, gener
await writeManifest(root, "packages/coding-agent", {
name: "@earendil-works/pi-coding-agent",
version: "2.0.0",
dependencies: {
"@earendil-works/pi-ai": "^2.0.0",
},
devDependencies: {
"@earendil-works/pi-ai": "~1.0.0",
},
optionalDependencies: {
"@earendil-works/pi-ai": "1.0.0",
},
});
await writeManifest(root, "packages/evals", {
name: "@earendil-works/pi-evals",
@ -57,12 +67,20 @@ test("synchronizes private dependencies without touching registry aliases, gener
const result = runSyncVersions(root);
assert.equal(result.status, 0, result.stderr);
const agentManifest = await readManifest(root, "packages/coding-agent");
for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
assert.equal(agentManifest[section]["@earendil-works/pi-ai"], "2.0.0");
}
const evalsManifest = await readManifest(root, "packages/evals");
assert.equal(evalsManifest.dependencies["@earendil-works/pi-coding-agent"], "^2.0.0");
assert.equal(evalsManifest.dependencies["@earendil-works/pi-coding-agent"], "2.0.0");
assert.equal(evalsManifest.dependencies["@mariozechner/pi-ai"], "npm:@earendil-works/pi-ai@1.0.0");
const generatedManifest = await readManifest(root, "packages/coding-agent/install-lock");
assert.equal(generatedManifest.dependencies["@earendil-works/pi-coding-agent"], "^1.0.0");
const alreadySynced = runSyncVersions(root);
assert.equal(alreadySynced.status, 0, alreadySynced.stderr);
assert.match(alreadySynced.stdout, /All inter-package dependencies are already in sync/);
await writeManifest(root, "packages/ai", {
name: "@earendil-works/pi-ai",
version: "3.0.0",