From d05a73fb31ea1c55c861115306df8f358a120de1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 15 Oct 2026 20:15:51 +0200 Subject: [PATCH] fix: keep the Nix model catalog pin buildable The pin script discovered the catalog with the checkout's version, which on main is the last release, so pi.dev answered with the catalog for released clients after MINIMUM_PI_VERSION was raised. It now pins the newest published catalog and treats the pin as stale when its provider/type/API model groups differ. The release script pins the newest catalog and runs the Nix build steps before tagging. closes #10985 --- .github/workflows/nix.yml | 11 ++-- .pi/skills/release.md | 2 +- nix/model-catalog.json | 2 +- packages/coding-agent/CHANGELOG.md | 1 + scripts/release.mjs | 56 +++++++++++++++--- scripts/update-model-catalog-pin.mjs | 70 +++++++++++++++-------- scripts/update-model-catalog-pin.test.mjs | 46 +++++++++++---- 7 files changed, 139 insertions(+), 49 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 61350c78a..17d350c84 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -2,11 +2,12 @@ name: Nix # The flake builds with the model catalog revision pinned in # nix/model-catalog.json. This workflow keeps that pin usable independently of -# releases: after pi.dev publishes a catalog from main, it replaces the pin only -# if the current one no longer hydrates main (for example after a provider was -# added) or lacks a model type the live catalog has for one of main's providers -# (for example after a provider gained classifier models), builds the flake, -# and commits the new pin to main. +# releases: after pi.dev publishes a catalog from main, it replaces the pin with +# the newest catalog only if the current one no longer hydrates main (for +# example after a provider was added) or its provider/type/API model groups +# differ from the newest catalog for one of main's providers (for example after +# a provider gained classifier models or switched APIs), builds the flake, and +# commits the new pin to main. # # Release tags are built exactly as tagged, with their committed pin. Once all # systems build, the stable branch is fast-forwarded to the tag so that diff --git a/.pi/skills/release.md b/.pi/skills/release.md index 912b976af..754b484a9 100644 --- a/.pi/skills/release.md +++ b/.pi/skills/release.md @@ -43,7 +43,7 @@ Run repository commands from the repo root (two directories above this skill), u Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or install lock diffs the release creates before push. - The release script refreshes the Nix model catalog pin (`nix/model-catalog.json`) if stale, bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. + The release script pins the newest published model catalog for Nix (`nix/model-catalog.json`) and builds the checkout with it as the Nix build does (it fails if that catalog predates main's model changes; publish one first), bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. 4. **CI verifies and announces the npm release**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. After publishing, `announce-pi-dev-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. `pi.dev/api/latest-version` reads that marker; it must never announce a release from npm before this job succeeds. diff --git a/nix/model-catalog.json b/nix/model-catalog.json index 3892f8199..a30c08655 100644 --- a/nix/model-catalog.json +++ b/nix/model-catalog.json @@ -1,3 +1,3 @@ { - "revision": "sha256-439c53478c84ed27a58f8b54e1c3bd45810e5a969515b4e3666a3898d51ee22f" + "revision": "sha256-ffa3e98626230f378f80200debb736c4a2238b5f2ba246a92cfc7d8e19604678" } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8def7b513..17487b67b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ - Fixed repeated codemode timeouts or aborts accumulating surviving workers; admission now limits live workers to two across sessions, counting cancelled workers until actual exit ([#10748](https://github.com/earendil-works/pi/issues/10748)) - Fixed `--model` choosing a provider by catalog order when several providers offer the match; Pi now prefers the provider with credentials and otherwise reports the ambiguity. `--models` and `enabledModels` add a model for each provider that offers it and warn about the ambiguity ([#10160](https://github.com/earendil-works/pi/issues/10160)) - Fixed `read` line-range labels for numeric-string or null arguments in the TUI, session tree, HTML exports, and examples ([#9887](https://github.com/earendil-works/pi/issues/9887)) +- Fixed the Nix flake build failing when a provider switched APIs: the model catalog pin is now refreshed from the newest published catalog when its model API groups differ, and releases pin the newest catalog and build with it before tagging ([#10985](https://github.com/earendil-works/pi/issues/10985)) - Fixed the Nix package putting its Node.js first on `PATH`, which made bash tool shells use Pi's Node instead of the user's `node`/`npm`. Pi's Node is now only a fallback ([#10519](https://github.com/earendil-works/pi/issues/10519)) - Fixed prompt templates dropping empty quoted arguments (`""` or `''`), which shifted later positional arguments and prevented positional defaults from applying ([#10867](https://github.com/earendil-works/pi/issues/10867)) diff --git a/scripts/release.mjs b/scripts/release.mjs index e5e5af6be..99fc6f0cf 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,8 +8,8 @@ * * Steps: * 1. Check for uncommitted changes - * 2. Verify every public workspace package is registered on npm - * and refresh the Nix model catalog pin if it is stale + * 2. Verify every public workspace package is registered on npm, + * pin the newest model catalog for Nix, and build with it * 3. Bump version via npm run version:xxx or set an explicit version * 4. Update CHANGELOG.md files: [Unreleased] -> [version] - date * 5. Regenerate release artifacts @@ -25,6 +25,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findPackageDirectories } from "./package-workspaces.mjs"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; +import { hydrateModelCatalogBytes, updateModelCatalogPin } from "./update-model-catalog-pin.mjs"; const RELEASE_TARGET = process.argv[2]; const BUMP_TYPES = new Set(["major", "minor", "patch"]); @@ -85,6 +86,48 @@ function assertPackagesAreRegisteredWithNpm() { console.log(" All public workspace packages are registered on npm\n"); } +const MODEL_CATALOG_PIN = "nix/model-catalog.json"; + +// Release tags are immutable, so the tagged Nix pin must already build the +// release. Pin the newest catalog unconditionally (`--if-stale` only avoids pin +// churn on main), then run the Nix build's steps: hydrate the checkout's model +// data from it and build offline. This catches e.g. source that references a +// model the catalog lacks. On failure, restore the committed pin so the +// worktree is clean for a rerun. +async function pinAndBuildWithNewestModelCatalog() { + const root = process.cwd(); + const committedPin = readFileSync(MODEL_CATALOG_PIN, "utf8"); + const restored = `${MODEL_CATALOG_PIN} was restored. Run \`npm run generate:models\` to restore local model data, then rerun the release.`; + let revision; + let result; + try { + const pin = await updateModelCatalogPin(root); + revision = pin.revision; + console.log(` Pinned model catalog ${revision}`); + hydrateModelCatalogBytes(root, pin.bytes); + console.log("$ npm run build:offline"); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + result = spawnSync(npm, ["run", "build:offline"], { stdio: "inherit" }); + } finally { + if (result?.status !== 0) writeFileSync(MODEL_CATALOG_PIN, committedPin); + } + // Only a build that ran and exited non-zero says anything about the catalog. + if (result.error || result.signal) { + const cause = result.error ? result.error.message : `it was stopped by ${result.signal}`; + console.error(`Error: npm run build:offline did not complete: ${cause}.\n${restored}`); + process.exit(1); + } + if (result.status !== 0) { + console.error( + `Error: the build with the newest published model catalog ${revision} failed; the Nix build of the release tag would fail too.\n` + + "If the errors concern model IDs, types, or APIs, the catalog predates main: publish one from main (Publish Model Catalog workflow).\n" + + "Otherwise fix the build (e.g. run `npm ci --ignore-scripts` if node_modules is out of date).\n" + + restored, + ); + process.exit(1); + } +} + function compareVersions(a, b) { const aParts = a.split(".").map(Number); const bParts = b.split(".").map(Number); @@ -225,11 +268,10 @@ console.log(" Working directory clean\n"); // 2. Verify npm package registration before modifying the worktree. assertPackagesAreRegisteredWithNpm(); -// Release tags are immutable, so the tagged Nix pin must already build the -// release. Refresh it before the version bump, while pi.dev still knows the -// current version, so it lands in the release commit. -console.log("Refreshing the Nix model catalog pin if stale..."); -run("node scripts/update-model-catalog-pin.mjs --if-stale"); +// Pin and verify the Nix model catalog before modifying versions, so the pin +// lands in the release commit and a failure leaves the worktree clean. +console.log("Pinning and building with the newest model catalog for Nix..."); +await pinAndBuildWithNewestModelCatalog(); console.log(); // 3. Bump or set version diff --git a/scripts/update-model-catalog-pin.mjs b/scripts/update-model-catalog-pin.mjs index 949030eb7..efec2af80 100644 --- a/scripts/update-model-catalog-pin.mjs +++ b/scripts/update-model-catalog-pin.mjs @@ -24,9 +24,14 @@ async function fetchRevision(revision) { return bytes; } -async function fetchLiveCatalog(root) { - const { version } = JSON.parse(readFileSync(join(root, "packages/coding-agent/package.json"), "utf8")); - const response = await fetch(`https://pi.dev/api/models?pi-version=${encodeURIComponent(version)}&${MODEL_TYPES}`); +/** + * Discover the newest published catalog. It is generated from main, which is + * what this checkout builds. Do not send `pi-version`: on main it is the last + * released version, and pi.dev answers it with the catalog for released + * clients, which predates any `MINIMUM_PI_VERSION` bump main depends on. + */ +async function fetchLiveCatalog() { + const response = await fetch(`https://pi.dev/api/models?${MODEL_TYPES}`); if (!response.ok) throw new Error(`Catalog discovery failed: HTTP ${response.status}`); const revision = response.headers.get("x-pi-model-catalog-revision"); if (typeof revision !== "string" || !REVISION_RE.test(revision)) { @@ -39,39 +44,49 @@ async function fetchLiveCatalog(root) { } /** - * Return the `provider/type` model groups the catalog provides for this + * Return the `provider/type/api` model groups the catalog provides for this * checkout's providers. The generated `*.models.ts` types are derived from the - * data, so code that uses a group (e.g. OPENCODE_CLASSIFIER_MODELS) only - * type-checks when the catalog has models of that type for the provider. + * data, which is grouped by API, so code only type-checks when the catalog has + * the same groups as main: e.g. OPENCODE_CLASSIFIER_MODELS needs opencode + * classifier models, and a provider factory returning + * `Provider<"openai-completions">` fails when its models use another API. */ -function modelTypeGroups(root, bytes) { +function modelApiGroups(root, bytes) { const catalog = JSON.parse(bytes.toString("utf8")); const groups = new Set(); for (const provider of readModelDataProviderIds(join(root, "packages/ai"))) { - for (const model of catalog[provider] ?? []) groups.add(`${provider}/${model.type}`); + for (const model of catalog[provider] ?? []) groups.add(`${provider}/${model.type}/${model.api}`); } return groups; } -/** Return why the catalog cannot hydrate this checkout, or undefined if it can. */ -function hydrationProblem(root, bytes) { +/** Hydrate the checkout's model data from catalog bytes, as the Nix build does. */ +export function hydrateModelCatalogBytes(root, bytes, options = {}) { const directory = mkdtempSync(join(tmpdir(), "pi-model-catalog-pin-")); try { const catalogPath = join(directory, "models.all.json"); writeFileSync(catalogPath, bytes); - hydrateModelCatalog(join(root, "packages/ai"), catalogPath, { validateOnly: true }); - return undefined; - } catch (error) { - return error instanceof Error ? error.message : String(error); + hydrateModelCatalog(join(root, "packages/ai"), catalogPath, options); } finally { rmSync(directory, { recursive: true, force: true }); } } +/** Return why the catalog cannot hydrate this checkout, or undefined if it can. */ +function hydrationProblem(root, bytes) { + try { + hydrateModelCatalogBytes(root, bytes, { validateOnly: true }); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + /** * Pin the live model catalog for Nix builds. With `ifStale`, keep the current - * pin while it still hydrates this checkout and has every model type the live - * catalog has for the checkout's providers. + * pin while it still hydrates this checkout and has the same model groups as + * the live catalog for the checkout's providers. Returns the pinned revision + * and its catalog bytes. */ export async function updateModelCatalogPin(root, { ifStale = false } = {}) { const pinPath = join(root, "nix/model-catalog.json"); @@ -87,21 +102,28 @@ export async function updateModelCatalogPin(root, { ifStale = false } = {}) { } } - const revision = await fetchLiveCatalog(root); + const revision = await fetchLiveCatalog(); // Do not record a pin until its immutable URL serves a catalog this checkout can build with. const bytes = await fetchRevision(revision); if (currentBytes) { - // The live catalog is generated from main. Main's code may use a model type - // the pin lacks, e.g. after a provider gained classifier models. - const pinned = modelTypeGroups(root, currentBytes); - const missing = [...modelTypeGroups(root, bytes)].filter((group) => !pinned.has(group)); - if (missing.length === 0) return { revision: current, updated: false }; - console.error(`Pinned model catalog is stale: missing model types ${missing.sort().join(", ")}`); + // The live catalog is generated from main. Main's code may depend on a model + // group the pin lacks, e.g. after a provider gained classifier models or + // switched APIs. + const pinned = modelApiGroups(root, currentBytes); + const live = modelApiGroups(root, bytes); + const missing = [...live].filter((group) => !pinned.has(group)).sort(); + const extra = [...pinned].filter((group) => !live.has(group)).sort(); + if (missing.length === 0 && extra.length === 0) return { revision: current, updated: false, bytes: currentBytes }; + const differences = [ + missing.length > 0 ? `missing ${missing.join(", ")}` : "", + extra.length > 0 ? `extra ${extra.join(", ")}` : "", + ].filter(Boolean); + console.error(`Pinned model catalog is stale: model groups differ (${differences.join("; ")})`); } const problem = hydrationProblem(root, bytes); if (problem) throw new Error(`Live model catalog ${revision} cannot hydrate this checkout: ${problem}`); writeFileSync(pinPath, `${JSON.stringify({ revision }, null, 2)}\n`); - return { revision, updated: revision !== current }; + return { revision, updated: revision !== current, bytes }; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/scripts/update-model-catalog-pin.test.mjs b/scripts/update-model-catalog-pin.test.mjs index 40adb1e35..f3790be07 100644 --- a/scripts/update-model-catalog-pin.test.mjs +++ b/scripts/update-model-catalog-pin.test.mjs @@ -22,6 +22,11 @@ const model = { const catalog = (models) => `${JSON.stringify({ "test-provider": models })}\n`; const revisionOf = (body) => `sha256-${createHash("sha256").update(body).digest("hex")}`; const pinFile = (revision) => `${JSON.stringify({ revision }, null, 2)}\n`; +const pinned = async (promise) => { + const { bytes, ...result } = await promise; + assert.equal(bytes.toString("utf8"), bodies.get(result.revision)); + return result; +}; const liveBody = catalog([model, { ...model, id: "model-b" }]); const liveRevision = revisionOf(liveBody); @@ -36,21 +41,23 @@ delete classifier.reasoning; delete classifier.maxTokens; const classifierBody = catalog([model, classifier]); const classifierRevision = revisionOf(classifierBody); +// Hydrates with the same model types, but main switched the provider's API (#10985). +const switchedBody = catalog([{ ...model, api: "anthropic-messages" }]); +const switchedRevision = revisionOf(switchedBody); const bodies = new Map([ [liveRevision, liveBody], [currentRevision, currentBody], [staleRevision, staleBody], [classifierRevision, classifierBody], + [switchedRevision, switchedBody], ]); const typed = "types=chat,image,classifier"; let root; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "pi-catalog-pin-")); - mkdirSync(join(root, "packages/coding-agent"), { recursive: true }); mkdirSync(join(root, "packages/ai/src/providers"), { recursive: true }); mkdirSync(join(root, "nix")); - writeFileSync(join(root, "packages/coding-agent/package.json"), JSON.stringify({ version: "0.85.1" })); writeFileSync( join(root, "packages/ai/src/models.generated.ts"), 'import { TEST_PROVIDER_CLASSIFIER_MODELS, TEST_PROVIDER_IMAGE_MODELS, TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n', @@ -84,25 +91,27 @@ function mockPiDev({ live = liveRevision, failure } = {}) { return requests; } -test("pins the live typed catalog after verifying its immutable URL", async () => { +test("pins the newest typed catalog after verifying its immutable URL", async () => { const requests = mockPiDev(); - assert.deepEqual(await updateModelCatalogPin(root), { revision: liveRevision, updated: true }); + assert.deepEqual(await pinned(updateModelCatalogPin(root)), { revision: liveRevision, updated: true }); + // No pi-version: main's version is the last release, which pi.dev answers + // with the catalog for released clients (#10985). assert.deepEqual(requests, [ - `https://pi.dev/api/models?pi-version=0.85.1&${typed}`, + `https://pi.dev/api/models?${typed}`, `https://pi.dev/api/models/revisions/${liveRevision}?${typed}`, ]); assert.equal(readPin(), pinFile(liveRevision)); }); -test("keeps a pin that still hydrates the checkout and has the live model types", async () => { +test("keeps a pin that still hydrates the checkout and has the live model groups", async () => { const requests = mockPiDev(); - assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), { + assert.deepEqual(await pinned(updateModelCatalogPin(root, { ifStale: true })), { revision: currentRevision, updated: false, }); assert.deepEqual(requests, [ `https://pi.dev/api/models/revisions/${currentRevision}?${typed}`, - `https://pi.dev/api/models?pi-version=0.85.1&${typed}`, + `https://pi.dev/api/models?${typed}`, `https://pi.dev/api/models/revisions/${liveRevision}?${typed}`, ]); assert.equal(readPin(), pinFile(currentRevision)); @@ -112,19 +121,34 @@ test("replaces a pin that lacks a model type of the live catalog", async (t) => const errors = []; t.mock.method(console, "error", (message) => errors.push(message)); mockPiDev({ live: classifierRevision }); - assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), { + assert.deepEqual(await pinned(updateModelCatalogPin(root, { ifStale: true })), { revision: classifierRevision, updated: true, }); assert.equal(readPin(), pinFile(classifierRevision)); - assert.deepEqual(errors, ["Pinned model catalog is stale: missing model types test-provider/classifier"]); + assert.deepEqual(errors, [ + "Pinned model catalog is stale: model groups differ (missing test-provider/classifier/openai-completions)", + ]); +}); + +// #10985 +test("replaces a pin whose provider uses a different API than the live catalog", async (t) => { + const errors = []; + t.mock.method(console, "error", (message) => errors.push(message)); + writeFileSync(join(root, "nix/model-catalog.json"), pinFile(switchedRevision)); + mockPiDev(); + assert.deepEqual(await pinned(updateModelCatalogPin(root, { ifStale: true })), { revision: liveRevision, updated: true }); + assert.equal(readPin(), pinFile(liveRevision)); + assert.deepEqual(errors, [ + "Pinned model catalog is stale: model groups differ (missing test-provider/chat/openai-completions; extra test-provider/chat/anthropic-messages)", + ]); }); test("replaces a pin that no longer hydrates the checkout", async (t) => { t.mock.method(console, "error", () => {}); writeFileSync(join(root, "nix/model-catalog.json"), pinFile(staleRevision)); mockPiDev(); - assert.deepEqual(await updateModelCatalogPin(root, { ifStale: true }), { revision: liveRevision, updated: true }); + assert.deepEqual(await pinned(updateModelCatalogPin(root, { ifStale: true })), { revision: liveRevision, updated: true }); assert.equal(readPin(), pinFile(liveRevision)); });