From f13c00fb3a4d77ad3faa815057e31d614d42b3f1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 13 Oct 2026 14:27:38 +0200 Subject: [PATCH] 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 --- AGENTS.md | 2 +- package-lock.json | 28 ++++----- packages/agent/package.json | 2 +- packages/ai/package.json | 2 +- packages/coding-agent/CHANGELOG.md | 4 ++ .../install-lock/package-lock.json | 14 ++--- packages/coding-agent/package.json | 10 +-- packages/durable/package.json | 6 +- packages/env/package.json | 4 +- packages/evals/package.json | 4 +- scripts/check-pinned-deps.mjs | 9 +-- scripts/check-pinned-deps.test.mjs | 63 +++++++++++++++++++ scripts/sync-versions.js | 9 ++- scripts/sync-versions.test.mjs | 22 ++++++- 14 files changed, 129 insertions(+), 50 deletions(-) create mode 100644 scripts/check-pinned-deps.test.mjs diff --git a/AGENTS.md b/AGENTS.md index f10ed3abc..00ae93315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/package-lock.json b/package-lock.json index 10013e171..9ea2de084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/agent/package.json b/packages/agent/package.json index 0584afeb0..d53790204 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -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": [ diff --git a/packages/ai/package.json b/packages/ai/package.json index 7cd22c9e8..941e8fcc3 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3aab1da79..ec72a6823 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 12eeba2c6..a78e74890 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e6daaaeea..74990aed6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/durable/package.json b/packages/durable/package.json index 8dfb5a376..b742a3419 100644 --- a/packages/durable/package.json +++ b/packages/durable/package.json @@ -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" diff --git a/packages/env/package.json b/packages/env/package.json index 9f09e1a81..70a65f6fb 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -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", diff --git a/packages/evals/package.json b/packages/evals/package.json index 100506ff5..0a2e1c8e8 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -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", diff --git a/scripts/check-pinned-deps.mjs b/scripts/check-pinned-deps.mjs index 991697205..cf477a243 100644 --- a/scripts/check-pinned-deps.mjs +++ b/scripts/check-pinned-deps.mjs @@ -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); } diff --git a/scripts/check-pinned-deps.test.mjs b/scripts/check-pinned-deps.test.mjs new file mode 100644 index 000000000..4ad51b05a --- /dev/null +++ b/scripts/check-pinned-deps.test.mjs @@ -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/); +}); diff --git a/scripts/sync-versions.js b/scripts/sync-versions.js index 918112364..63ccb8ccc 100644 --- a/scripts/sync-versions.js +++ b/scripts/sync-versions.js @@ -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); diff --git a/scripts/sync-versions.test.mjs b/scripts/sync-versions.test.mjs index 173c18f0b..628b964c0 100644 --- a/scripts/sync-versions.test.mjs +++ b/scripts/sync-versions.test.mjs @@ -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",