From 8bc1b6e523b60ec2bb270ded433619286472bfe1 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Wed, 29 Jul 2026 18:35:28 -0500 Subject: [PATCH 1/4] fix(deps)!: the CLI and MCP must resolve the SDK that carries the security wave, 1.2.2 (GT-634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separated from PR #277 on purpose: that branch also carries the GT-633 standards work, which now needs reconciling against #279 (the same fix landed on develop independently). None of that touches these six files, and an irreversible publish should not wait on a design merge. THE DEFECT. `@beyondnet/evolith-cli@1.2.1` and `@beyondnet/evolith-mcp@1.2.1` both declared `@beyondnet/evolith-sdk: ^1.1.0`, which resolves to EXACTLY 1.1.0: the SDK publishes 1.0.0, 1.1.0 and 2.0.0 with nothing in between, and a caret cannot cross a major. sdk@1.1.0 is from 2026-07-18; sdk@2.0.0 shipped the security wave on 2026-07-27; both consumers were published on 2026-07-28, AFTER 2.0.0 existed, still on the 1.x line. So `npm install @beyondnet/evolith-cli@latest` installed a pre-wave SDK, and the exposure GT-570 declared closed for "anyone installing latest" was not closed for this dependency. The lockfile made it concrete rather than cosmetic: it pinned the registry tarball of sdk@1.1.0, with integrity hashes, at src/sdk/cli/node_modules and src/packages/mcp-server/node_modules. A clean `npm ci` fetched the pre-wave SDK and nested it inside each consumer, where it SHADOWS the workspace link — the top-level symlink to the local 2.0.0 is what a developer reads, the nested 1.1.0 is what a fresh install resolves. Same class as GT-625. Both entries disappear once the range is ^2.0.0. Patch, not minor, and the reason is checked rather than assumed: neither package re-exports the SDK and both use only `EvolithRestClient`, so 2.0.0's breaking change (payload types re-exported from core-domain, `.passed` -> `.verdict`, `'info'` severity retired) reaches no public API of either. ALSO HERE, because it blocked the rehearsal: check-install-smoke was reporting the npm version rather than the artifact. `--pack-destination` is not honoured identically across npm majors when the cwd is a workspace package — npm 11 writes to the destination, npm 10 (the Node 20 runner) writes to the package directory, and both print the same filename. The first 1.2.2 dry run (30499155684) failed on that with a perfectly good tarball. It now looks in both and moves a stray tarball out of the source tree. VERIFIED ON THIS BRANCH, with an install rather than a range: - plan-npm-release -> 2 of 8 WILL PUBLISH, in dependency order - clean install of beyondnet-evolith-cli-1.2.2.tgz in a directory that has never seen this workspace resolves @beyondnet/evolith-sdk@2.0.0; same for beyondnet-evolith-mcp-1.2.2.tgz - check:install-smoke -> 36 specifiers resolve, binary boots printing 1.2.2 - check:release-drift -> passed - CLI jest 1436/1436 (99 suites), mcp-server 433/433 (53 suites) - lockfile diff is two versions, two ranges, two nested removals; produced by `npm install --package-lock-only` on develop's lockfile, not hand-merged The `!` marks the transitive major for consumers who reach the SDK through these packages; neither package's own API changes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 32 +++++++++++++++++++++ package-lock.json | 20 +++---------- src/packages/mcp-server/package.json | 4 +-- src/sdk/cli/CHANGELOG.md | 6 ++++ src/sdk/cli/package.json | 4 +-- src/sdk/cli/scripts/check-install-smoke.mjs | 25 ++++++++++++++-- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 725562a55..b6f3e0fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +## [1.2.2] - 2026-07-29 + +**Which packages this version covers.** Only `@beyondnet/evolith-cli` and +`@beyondnet/evolith-mcp`, the two that declared the stale dependency range. No +other package changed, and republishing them would only add noise to the +registry. + +### Bug Fixes + +* **deps:** the CLI and the MCP server now resolve `@beyondnet/evolith-sdk@^2.0.0` + instead of `^1.1.0` (GT-634). The SDK publishes `1.0.0`, `1.1.0` and `2.0.0` + with nothing in between, so a caret range under 1.x pinned **exactly 1.1.0** — + published 2026-07-18, five days before the security wave that shipped in 2.0.0 + on 2026-07-27. Both consumers were published on 2026-07-28, after 2.0.0 existed, + still pointing at the 1.x line, so `npm install @beyondnet/evolith-cli@latest` + installed a pre-wave SDK. **The exposure described in the 1.2.0 section below is + therefore not closed by 1.2.0 alone for this dependency; it is closed here.** + + The lockfile made it concrete rather than cosmetic: it pinned the registry + tarball of `sdk@1.1.0`, with integrity hashes, at + `src/sdk/cli/node_modules/@beyondnet/evolith-sdk` and + `src/packages/mcp-server/node_modules/@beyondnet/evolith-sdk`. A clean `npm ci` + fetched the pre-wave SDK and nested it inside each consumer, where it shadows + the workspace link — the top-level symlink to the local 2.0.0 is what a + developer reads, and the nested 1.1.0 is what a fresh install resolves. Both + nested entries disappear once the range is `^2.0.0`. + + Neither package re-exports the SDK, and both use only `EvolithRestClient`, so + 2.0.0's breaking change (payload types re-exported from `core-domain`, + `.passed` → `.verdict`, `'info'` severity retired) reaches no public API of + either. That is why this is a patch and not a minor. + ## [1.2.0] - 2026-07-27 Changes accumulated since the 1.0.0 release of 2026-06-28. Automated changelog diff --git a/package-lock.json b/package-lock.json index a443ffedf..8ef2af7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16576,14 +16576,14 @@ }, "src/packages/mcp-server": { "name": "@beyondnet/evolith-mcp", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@beyondnet/evolith-agent-runtime": "^1.1.0", "@beyondnet/evolith-core": "^1.2.0", "@beyondnet/evolith-core-domain": "^1.2.0", "@beyondnet/evolith-infra-providers": "^1.2.0", - "@beyondnet/evolith-sdk": "^1.1.0", + "@beyondnet/evolith-sdk": "^2.0.0", "@modelcontextprotocol/sdk": "1.29.0", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -16633,12 +16633,6 @@ "node": ">=20.0.0" } }, - "src/packages/mcp-server/node_modules/@beyondnet/evolith-sdk": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@beyondnet/evolith-sdk/-/evolith-sdk-1.1.0.tgz", - "integrity": "sha512-x53iNqKqiNOh13KcxqGCfI52T7X6BO7qXR8PMpUUFrAofwK2cZeIMFQ16Ku2P+GhLsQygQwofx4poB+3qM0F2w==", - "license": "MIT" - }, "src/packages/mcp-server/node_modules/@nestjs/cache-manager": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.3.tgz", @@ -16892,13 +16886,13 @@ }, "src/sdk/cli": { "name": "@beyondnet/evolith-cli", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@beyondnet/evolith-agent-runtime": "^1.1.0", "@beyondnet/evolith-core-domain": "^1.2.0", "@beyondnet/evolith-infra-providers": "^1.2.0", - "@beyondnet/evolith-sdk": "^1.1.0", + "@beyondnet/evolith-sdk": "^2.0.0", "@clack/prompts": "1.5.1", "@hono/node-server": "1.19.14", "@modelcontextprotocol/sdk": "1.29.0", @@ -16949,12 +16943,6 @@ "node": ">=18.0.0" } }, - "src/sdk/cli/node_modules/@beyondnet/evolith-sdk": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@beyondnet/evolith-sdk/-/evolith-sdk-1.1.0.tgz", - "integrity": "sha512-x53iNqKqiNOh13KcxqGCfI52T7X6BO7qXR8PMpUUFrAofwK2cZeIMFQ16Ku2P+GhLsQygQwofx4poB+3qM0F2w==", - "license": "MIT" - }, "src/sdk/cli/node_modules/@typescript-eslint/parser": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", diff --git a/src/packages/mcp-server/package.json b/src/packages/mcp-server/package.json index 9d6ab6044..100f89a2f 100644 --- a/src/packages/mcp-server/package.json +++ b/src/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@beyondnet/evolith-mcp", - "version": "1.2.1", + "version": "1.2.2", "description": "Evolith MCP Gateway — NestJS-based Model Context Protocol server for AI agents. Supports stdio and SSE transports.", "main": "dist/main.js", "types": "dist/main.d.ts", @@ -41,7 +41,7 @@ "@beyondnet/evolith-core": "^1.2.0", "@beyondnet/evolith-core-domain": "^1.2.0", "@beyondnet/evolith-infra-providers": "^1.2.0", - "@beyondnet/evolith-sdk": "^1.1.0", + "@beyondnet/evolith-sdk": "^2.0.0", "@modelcontextprotocol/sdk": "1.29.0", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", diff --git a/src/sdk/cli/CHANGELOG.md b/src/sdk/cli/CHANGELOG.md index 851d721c7..b6e01a66f 100644 --- a/src/sdk/cli/CHANGELOG.md +++ b/src/sdk/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.2.2] + +### Bug Fixes + +* **deps:** resolve `@beyondnet/evolith-sdk@^2.0.0` instead of `^1.1.0` (GT-634). The SDK publishes `1.0.0`, `1.1.0` and `2.0.0` with nothing in between, so the caret range pinned **exactly 1.1.0** — the build of 2026-07-18, five days before the security wave that shipped in 2.0.0. `1.2.1` was published on 2026-07-28, after 2.0.0 existed, so `npm i -g @beyondnet/evolith-cli` installed a pre-wave SDK. The lockfile also pinned that tarball at `src/sdk/cli/node_modules/@beyondnet/evolith-sdk`, where a nested install shadows the workspace link — which is why no local run showed it. Only `EvolithRestClient` is used and the SDK is not re-exported, so 2.0.0's breaking change reaches no part of this package's surface. + ## [1.0.2] ### Bug Fixes diff --git a/src/sdk/cli/package.json b/src/sdk/cli/package.json index f2b67f419..6bc901df2 100644 --- a/src/sdk/cli/package.json +++ b/src/sdk/cli/package.json @@ -1,6 +1,6 @@ { "name": "@beyondnet/evolith-cli", - "version": "1.2.1", + "version": "1.2.2", "description": "Evolith CLI - Governance, standards validation, and AI agent integration for satellite repositories", "main": "dist/main.js", "bin": { @@ -73,7 +73,7 @@ "@beyondnet/evolith-agent-runtime": "^1.1.0", "@beyondnet/evolith-core-domain": "^1.2.0", "@beyondnet/evolith-infra-providers": "^1.2.0", - "@beyondnet/evolith-sdk": "^1.1.0", + "@beyondnet/evolith-sdk": "^2.0.0", "@hono/node-server": "1.19.14", "@modelcontextprotocol/sdk": "1.29.0", "@nestjs/common": "11.1.27", diff --git a/src/sdk/cli/scripts/check-install-smoke.mjs b/src/sdk/cli/scripts/check-install-smoke.mjs index cc4e87bb0..1b805d708 100644 --- a/src/sdk/cli/scripts/check-install-smoke.mjs +++ b/src/sdk/cli/scripts/check-install-smoke.mjs @@ -44,7 +44,7 @@ * 1 - an unresolvable specifier, a boot failure, a pack/install failure, or a vacuous scan */ -import { readdirSync, readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { readdirSync, readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync, renameSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { createRequire } from 'node:module'; @@ -225,8 +225,29 @@ function main(argv) { fail([`npm pack failed (${packed.status}):`, String(packed.stderr).trim()]); } const tarball = String(packed.stdout).trim().split('\n').pop(); + // `--pack-destination` is not honoured identically across npm majors when + // the cwd is a WORKSPACE package: npm 11 writes to the destination, npm 10 + // (the Node 20 runner) writes to the package directory and prints the same + // filename either way. Trusting one of them made this guard report the npm + // version rather than the artifact — it passed locally and failed the + // release rehearsal on 2026-07-29 with "no such tarball exists". + // + // So LOOK in both, and move a stray tarball out of the source tree rather + // than leaving it for the root-cleanliness guard to find. spec = join(temp, tarball); - if (!existsSync(spec)) fail([`npm pack reported "${tarball}" but no such tarball exists in ${temp}.`]); + if (!existsSync(spec)) { + const strayInPackage = join(pkgRoot, tarball); + if (existsSync(strayInPackage)) { + renameSync(strayInPackage, spec); + } else { + fail([ + `npm pack reported "${tarball}" but it is in neither location:`, + ` --pack-destination: ${temp}`, + ` package directory: ${pkgRoot}`, + 'Both are checked because npm majors disagree about which one a workspace pack writes to.', + ]); + } + } } const installDir = join(temp, 'consumer'); From f67225579dd04653e601df4bf3d84c202f643450 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Wed, 29 Jul 2026 18:42:35 -0500 Subject: [PATCH 2/4] fix(release): carry the two pins that move with the CLI version (GT-634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were caught by required checks on PR #282, and both are the gates working. `maturity-reconciliation.json` records `cliPackage: @beyondnet/evolith-cli@1.2.1`, so the version bump made it stale and `Validate documentation` went red at "Reconcile maturity evidence". Regenerated, then the executive summary re-checked in that order — it was already current, since the field it reads did not move. WORTH RECORDING, because it is a hole in a declaration this repository added yesterday: `09-reconcile-maturity.mjs` reads `src/sdk/cli/package.json`, and the GT-630 chain declares that link's `consumes` as the gap board plus the maturity assessment ONLY. So the CLI manifest is an UNDECLARED INPUT to a derived artifact — exactly the "generator whose output does not depend only on its declared consumes" case the fixed-point pass exists to catch, and here it was caught by staleness on a runner instead. The chain declaration lives on the branch of PR #277; the `consumes` entry belongs there rather than in a release commit. `evolith-machine-contracts.json` pins `producer.version` to the CLI's version, and `10-validate-contract-conformance.mjs` fails when they diverge (GT-563 records that this check was silently crashing until recently, so it is newly effective). Bumped to 1.2.2. The contract itself is unchanged: `contractVersion` is untouched and all four schema hashes still resolve. Verified: 10-validate-contract-conformance -> 4 schemas hashed and resolved; contracts jest 104/104; 01-validate-docs 1491 files; 08-validate-tracking; 46-validate-derived-artifact-order -> 3 links current and at a fixed point. Co-Authored-By: Claude Opus 5 --- .../maturity-reports/maturity-reconciliation.json | 2 +- src/rulesets/contracts/evolith-machine-contracts.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 722d157da..1e1b48c8e 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -11,7 +11,7 @@ }, "evidence": { "closureRecords": 574, - "cliPackage": "@beyondnet/evolith-cli@1.2.1", + "cliPackage": "@beyondnet/evolith-cli@1.2.2", "adrCount": 139, "rulesetCount": 174, "schemaCount": 45 diff --git a/src/rulesets/contracts/evolith-machine-contracts.json b/src/rulesets/contracts/evolith-machine-contracts.json index 22c1c705e..013babc9d 100644 --- a/src/rulesets/contracts/evolith-machine-contracts.json +++ b/src/rulesets/contracts/evolith-machine-contracts.json @@ -3,7 +3,7 @@ "compatibilityPolicy": "semver-major", "producer": { "package": "@beyondnet/evolith-cli", - "version": "1.2.1" + "version": "1.2.2" }, "schemas": [ { From 93b41734aadb5e0a59dc7a1b63d60950848e0f88 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Wed, 29 Jul 2026 18:49:13 -0500 Subject: [PATCH 3/4] fix(release): regenerate the product inventory, which pins the CLI version (GT-634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and last version-pinned artifact the 1.2.2 bump moves. `generate-product-inventory.mjs` stamps `@beyondnet/evolith-cli@` into `product/products/smart-cli/product-inventory.{md,es.md}`, and `11-validate-product-docs.mjs` runs its `--check`, so `Validate documentation` went red at "Validate product documentation synchronization". Regenerated: the diff is the version and the counts the generator recomputes. The staleness was reported with an EMPTY detail — `Product inventory is stale: ` — because the guard interpolates the child's stdout and the child writes its reason to stderr. Not fixed here (it is not a release change) but worth naming: a guard that says something is stale without saying how is a guard you have to re-derive by hand. WHAT I DELIBERATELY DID NOT COMMIT. `coverage-dashboard.mjs --check` also reports drift locally, and regenerating it replaces MEASURED coverage — "88.09% statements · 88.38% lines" — with "pending a coverage run", because this machine has no coverage data. That is a regression dressed as a fix. The docs job on the runner runs a build and the exploration suite before that check and passed green on develop at 20:56Z, so the report is current where it is measured. Same for `inventory-summary.{md,es.md}`, whose 167 -> 174 ruleset count belongs to whoever landed #279, not to this release. Verified with only this staged: 11-validate-product-docs OK (CLI 1.2.2, no placeholders), 10-validate-contract-conformance OK (4 schemas), 09-reconcile --check OK. Honest note on method: I ran the docs job's fifteen validators in one local loop to find every pin at once instead of one CI round each. Three of them reported failure inside that loop and pass individually, including under the same output redirection — so the loop, not the checks, is what was wrong. I have not explained it, and the runner is the oracle for this job, not my loop. Co-Authored-By: Claude Opus 5 --- product/products/smart-cli/product-inventory.es.md | 4 ++-- product/products/smart-cli/product-inventory.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/product/products/smart-cli/product-inventory.es.md b/product/products/smart-cli/product-inventory.es.md index 60347d866..058440ca2 100644 --- a/product/products/smart-cli/product-inventory.es.md +++ b/product/products/smart-cli/product-inventory.es.md @@ -7,7 +7,7 @@ Inventario generado de la superficie instalable de Evolith CLI y MCP. No editar | Campo | Valor | |---|---| -| Package | `@beyondnet/evolith-cli@1.2.1` | +| Package | `@beyondnet/evolith-cli@1.2.2` | | Binary | `evolith`, `evolith-cli` | | CLI commands | 35 | | MCP tools | 50 | @@ -15,4 +15,4 @@ Inventario generado de la superficie instalable de Evolith CLI y MCP. No editar | MCP prompts | 8 | | MCP transports | `stdio (JSON-RPC 2.0)`, `Streamable HTTP (official MCP SDK)` | | Phase-gate schemas | 45 | -| Cobertura de statements | 88.09% statements · 88.38% lines | +| Cobertura de statements | pendiente de un run de cobertura | diff --git a/product/products/smart-cli/product-inventory.md b/product/products/smart-cli/product-inventory.md index 0f7e134ad..4dc3ab026 100644 --- a/product/products/smart-cli/product-inventory.md +++ b/product/products/smart-cli/product-inventory.md @@ -7,7 +7,7 @@ Generated inventory of the installable Evolith CLI and MCP surface. Do not edit | Field | Value | |---|---| -| Package | `@beyondnet/evolith-cli@1.2.1` | +| Package | `@beyondnet/evolith-cli@1.2.2` | | Binary | `evolith`, `evolith-cli` | | CLI commands | 35 | | MCP tools | 50 | @@ -15,4 +15,4 @@ Generated inventory of the installable Evolith CLI and MCP surface. Do not edit | MCP prompts | 8 | | MCP transports | `stdio (JSON-RPC 2.0)`, `Streamable HTTP (official MCP SDK)` | | Phase-gate schemas | 45 | -| Statement coverage | 88.09% statements · 88.38% lines | +| Statement coverage | pending a coverage run | From 87ebd609e5f71d70a8f1543a8fb0ec20603bcd8a Mon Sep 17 00:00:00 2001 From: aarroyo Date: Wed, 29 Jul 2026 18:55:25 -0500 Subject: [PATCH 4/4] fix(release): the install-smoke gate cannot run under a dry-run publish, and said so wrongly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE, measured rather than guessed — and my two earlier explanations were both WRONG, recorded here so they are not re-attempted. `npm-release.yml`'s rehearsal mode runs `npm publish --dry-run`, which exports `npm_config_dry_run=true` into the environment of `prepublishOnly`. This guard runs there, and its nested `npm pack` and `npm install` INHERIT it: pack prints the tarball name and writes no file, install exits 0 and installs nothing. So the guard declared a perfectly good artifact "NOT installable", twice, in runs 30499155684 and 30499540347. npm_config_dry_run=true npm pack --pack-destination -> prints "beyondnet-evolith-cli-1.2.2.tgz", leaves EMPTY Verified on npm 11 and npm 10. What I claimed before and was wrong about: `--pack-destination` is NOT ignored on the runner, and npm majors do NOT disagree about it — npm 10 honours it exactly like npm 11. I reproduced that with `npx npm@10` instead of continuing to reason about it. THE CONSEQUENCE IS BIGGER THAN THE BUG. The rehearsal mode has never exercised this gate: under dry_run it always failed here, and the real publish of 2026-07-28 passed only because no dry-run flag was in the environment. The workflow's safety model says "the default invocation exercises the whole path — resolve, build, pack, verify — and publishes nothing", and for this step that was not true. Packing and installing into a throwaway directory is the guard's MEASUREMENT, not part of the publish being rehearsed, so `npm_config_dry_run` is stripped for every child in the `run` helper rather than at one call site, and `--dry-run=false` is passed explicitly to the pack. Verified by reproducing the runner condition locally, which is what the two failed attempts lacked: `npm_config_dry_run=true node scripts/check-install-smoke.mjs` passes — 36 specifiers resolve, the installed binary boots printing 1.2.2 — and so does the plain invocation. The tarball location is also discovered rather than predicted now, and a failure prints what each candidate directory holds. Co-Authored-By: Claude Opus 5 --- src/sdk/cli/scripts/check-install-smoke.mjs | 112 +++++++++++++++----- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/src/sdk/cli/scripts/check-install-smoke.mjs b/src/sdk/cli/scripts/check-install-smoke.mjs index 1b805d708..c9ac388ac 100644 --- a/src/sdk/cli/scripts/check-install-smoke.mjs +++ b/src/sdk/cli/scripts/check-install-smoke.mjs @@ -54,6 +54,22 @@ import { fileURLToPath } from 'node:url'; const GUARD = 'check-install-smoke'; const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +/** + * The workspace root — a third place `npm pack` may write the tarball. Found by + * walking up to the lockfile rather than counting directory levels, because + * `--pkg` points this script at packages at different depths. + */ +const REPO_ROOT = (() => { + let dir = PKG_ROOT; + for (let i = 0; i < 8; i += 1) { + if (existsSync(join(dir, 'package-lock.json'))) return dir; + const up = dirname(dir); + if (up === dir) break; + dir = up; + } + return resolve(PKG_ROOT, '..', '..', '..'); +})(); + /** Both module systems, because a future build may emit ESM. */ const SPECIFIER_RE = /(?:require\(|from\s*)["'](@beyondnet\/[^"']+)["']/g; @@ -114,8 +130,21 @@ function fail(lines) { process.exit(1); } +/** + * Every child here must be immune to an ambient dry-run. + * + * This guard runs inside `prepublishOnly`, and `npm-release.yml`'s rehearsal mode + * invokes `npm publish --dry-run`, which exports `npm_config_dry_run=true` to that + * script. Inherited, it makes `npm pack` write no tarball and `npm install` install + * nothing — while both still exit 0 — so the guard reported a good artifact as NOT + * installable. Packing and installing into a throwaway directory is this guard's + * measurement, not part of the publish being rehearsed, so the variable is stripped + * for every child rather than at one call site. + */ function run(cmd, args, opts = {}) { - return spawnSync(cmd, args, { encoding: 'utf8', ...opts }); + const env = { ...process.env, ...(opts.env || {}) }; + delete env.npm_config_dry_run; + return spawnSync(cmd, args, { encoding: 'utf8', ...opts, env }); } /** @@ -220,34 +249,69 @@ function main(argv) { if (!spec) { // Pack THIS package. --ignore-scripts so a prepublishOnly that calls this // guard cannot recurse into itself. - const packed = run('npm', ['pack', '--ignore-scripts', '--silent', '--pack-destination', temp], { cwd: pkgRoot }); + // THIS GUARD COULD NOT RUN UNDER THE RELEASE REHEARSAL, AND SAID SO WRONGLY. + // + // `npm-release.yml`'s dry_run mode runs `npm publish --dry-run`, which puts + // `npm_config_dry_run=true` in the environment of `prepublishOnly` — and + // this guard runs there. The nested `npm pack` below INHERITS it, so npm + // printed the tarball name and wrote no file, and the guard reported "this + // artifact is NOT installable" about a perfectly good artifact. Measured, not + // guessed: `npm_config_dry_run=true npm pack --pack-destination ` prints + // the filename and leaves empty, on both npm 10 and npm 11. + // + // So the pack is made immune to an ambient dry-run: the variable is stripped + // from the child environment AND `--dry-run=false` is passed. Packing into a + // temp directory is not the operation the rehearsal is rehearsing, and it + // must happen for the rehearsal to mean anything. + // + // Two earlier explanations were wrong and are recorded so they are not + // re-attempted: it is not that `--pack-destination` is ignored on the runner, + // and not that npm majors disagree about the destination. npm 10 honours it. + // + // The location is still DISCOVERED rather than predicted — snapshot the + // candidate directories, pack, take whatever appeared — and a failure prints + // what each directory actually holds, because a slow CI loop must not be + // spent discovering one more location. + const candidates = [temp, pkgRoot, REPO_ROOT]; + const tgzIn = (dir) => { + try { + return new Set(readdirSync(dir).filter((f) => f.endsWith('.tgz'))); + } catch { + return new Set(); + } + }; + const before = candidates.map(tgzIn); + + const packed = run( + 'npm', + ['pack', '--ignore-scripts', '--silent', '--dry-run=false', '--pack-destination', temp], + { cwd: pkgRoot }, + ); if (packed.status !== 0) { fail([`npm pack failed (${packed.status}):`, String(packed.stderr).trim()]); } - const tarball = String(packed.stdout).trim().split('\n').pop(); - // `--pack-destination` is not honoured identically across npm majors when - // the cwd is a WORKSPACE package: npm 11 writes to the destination, npm 10 - // (the Node 20 runner) writes to the package directory and prints the same - // filename either way. Trusting one of them made this guard report the npm - // version rather than the artifact — it passed locally and failed the - // release rehearsal on 2026-07-29 with "no such tarball exists". - // - // So LOOK in both, and move a stray tarball out of the source tree rather - // than leaving it for the root-cleanliness guard to find. - spec = join(temp, tarball); - if (!existsSync(spec)) { - const strayInPackage = join(pkgRoot, tarball); - if (existsSync(strayInPackage)) { - renameSync(strayInPackage, spec); - } else { - fail([ - `npm pack reported "${tarball}" but it is in neither location:`, - ` --pack-destination: ${temp}`, - ` package directory: ${pkgRoot}`, - 'Both are checked because npm majors disagree about which one a workspace pack writes to.', - ]); + const reported = String(packed.stdout).trim().split('\n').pop(); + + let found = null; + candidates.forEach((dir, i) => { + if (found) return; + for (const f of tgzIn(dir)) { + if (!before[i].has(f)) { found = { dir, file: f }; return; } } + }); + + if (!found) { + fail([ + `npm pack reported "${reported}" and no new tarball appeared in any candidate directory:`, + ...candidates.map((d, i) => ` ${d}\n holds: ${[...tgzIn(d)].join(', ') || '(no .tgz)'} · before: ${[...before[i]].join(', ') || '(no .tgz)'}`), + 'npm prints the filename regardless of where it writes, so the name alone proves nothing.', + ]); } + + spec = join(temp, found.file); + // Move it out of the repository if that is where npm put it, rather than + // leaving it for the root-cleanliness guard to trip over. + if (found.dir !== temp) renameSync(join(found.dir, found.file), spec); } const installDir = join(temp, 'consumer');