From c42d7407f55d2df93c4f2d8c7aca59ad186bb9a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:33:57 +0000 Subject: [PATCH 1/2] fix(publish-smoke): derive the tarball pin set from the publishable population, not a scope/exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed-tarball smoke built its pin set as "publishable MINUS a hand-written exclusion", and the exclusion held `create-objectstack` on the rationale that no @objectstack/* manifest depends on it. That rationale expired when @objectstack/cli took a dependency on the scaffolder: the unscoped name was neither packed nor pinned, pnpm fell back to the registry, and the smoke died on ERR_PNPM_NO_MATCHING_VERSION for a version that by definition is not published yet — a deterministic false red on every release candidate. - publish-smoke-pack.mjs: the set is `private !== true`, full stop. No scope filter, no exclusion, no hand list. `assertPinSetTotal` re-checks pin set == publishable set in BOTH directions at the derivation site and names the offending packages, so a future exclusion cannot reopen the hole silently. - publish-smoke.sh: the registry-leak assertion drove off a `@objectstack/*` grep, which made it blind in exactly the case it existed to catch. It now drives off the override map's own names, scoped and unscoped alike. - A --self-test (unscoped package survives derivation; both directions of the equality assertion) wired as `check:publish-smoke-pin` in lint.yml, so a regression reddens on the PR that causes it rather than on a release run. Co-Authored-By: Claude --- .github/workflows/lint.yml | 10 ++ .github/workflows/publish-smoke.yml | 5 +- package.json | 1 + scripts/publish-smoke-pack.mjs | 177 ++++++++++++++++++++++++++-- scripts/publish-smoke.sh | 58 +++++++-- 5 files changed, 226 insertions(+), 25 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 934124548e..f3f998ae87 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -416,6 +416,16 @@ jobs: - name: Part-of closing-keyword guard self-test run: pnpm check:partof-closing-keyword + # Publish-smoke tarball pin-set self-test. The assertion it pins lives on + # the RELEASE path (scripts/publish-smoke-pack.mjs runs only inside the + # packed-tarball smoke), so without this step a regression in it would be + # discovered by a release candidate — which is exactly how the unscoped + # `create-objectstack` hole surfaced: as a red on the operator's own + # release run, not on the PR that opened it. Pure functions, synthetic + # fixtures, no pnpm/workspace/network; ~0.05s. + - name: Publish-smoke pin-set self-test + run: pnpm check:publish-smoke-pin + # Single-claim path guard self-test (#9402). Same split as the step above # and for the same reason: the guard is a PR-scoped blocking check in its # own workflow, because its question is about OTHER open PRs and needs a diff --git a/.github/workflows/publish-smoke.yml b/.github/workflows/publish-smoke.yml index e898d5977e..e90b547346 100644 --- a/.github/workflows/publish-smoke.yml +++ b/.github/workflows/publish-smoke.yml @@ -13,8 +13,9 @@ # # pack-smoke SMOKE_MODE=pack — `pnpm pack` every publishable package # (pack applies the same manifest rewrites as publish), -# scaffold a fresh project OUTSIDE the workspace, pin -# @objectstack/* to the tarballs via the project's own pnpm +# scaffold a fresh project OUTSIDE the workspace, pin every +# publishable package — scoped and unscoped alike — to the +# tarballs via the project's own pnpm # overrides, and smoke it. This is "what 15.1.0 would have # failed": the release-candidate combination, no workspace # overrides in sight. diff --git a/package.json b/package.json index 5417f6b4b4..dccceb164f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", + "check:publish-smoke-pin": "node scripts/publish-smoke-pack.mjs --self-test", "check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test", "check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test", "check:pnpm-filter-targets": "node scripts/pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs", diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index 56981e0289..bd6fe231ea 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -16,6 +16,28 @@ * Packing everything keeps the overrides map total — a package missing from * it would make the smoke project resolve that name from the npm registry, * silently testing a published version instead of the candidate one. + * + * THE SET IS THE PUBLISHABLE POPULATION — never a scope glob, never a hand + * list, never an exclusion. This script used to carve `create-objectstack` + * out by name, on the rationale that "no @objectstack/* manifest depends on + * it". That rationale expired the day `@objectstack/cli` took a dependency on + * the scaffolder: cli@17.2.0 declared `create-objectstack@17.2.0`, the name + * was neither packed nor pinned, pnpm fell back to the registry, and the + * release candidate's own smoke died on ERR_PNPM_NO_MATCHING_VERSION for a + * version that by definition does not exist yet — a chicken-and-egg red on + * every release candidate from that day on. The general shape of that bill: + * whether a workspace package is *reachable* from some other manifest is a + * fact about the dependency graph AT ONE MOMENT, and it is not the question + * this script gets to ask. Publishable is the question, `private !== true` + * is the answer, and `assertPinSetTotal` below re-checks it on every run so + * a future exclusion cannot re-open the hole silently. + * + * Source of truth: the workspace itself. The Changesets `fixed` group in + * .changeset/config.json enumerates the same 69 names, but it is a DERIVED + * declaration validated against the workspace by scripts/check-changeset-fixed.mjs + * (which reddens both when a public package is missing from the group and + * when a group name no longer exists) — deriving from the group would mean + * reading a copy that a gate keeps honest, rather than the thing itself. */ import { execFile } from 'node:child_process'; @@ -23,22 +45,65 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; -const execFileP = promisify(execFile); +import { isEntrypoint } from './invoked-as.mjs'; -// Not consumed as npm dependencies by a scaffolded project: -// create-objectstack — the scaffolder itself; the smoke runs it straight -// from the repo's built bin, and no @objectstack/* manifest depends on it. -const EXCLUDE = new Set(['create-objectstack']); +const execFileP = promisify(execFile); const CONCURRENCY = 8; +/** + * The publishable population: every workspace member npm would receive. + * No scope filter — `create-objectstack` is unscoped and publishable, and + * the next unscoped public package must land in the set on its own. + * + * @param {{name?: string, private?: boolean}[]} all workspace members + * @returns {{name: string}[]} + */ +export function selectPublishable(all) { + return all.filter((p) => p.name && p.private !== true); +} + +/** + * The pin set MUST equal the publishable set, both directions. A hole in + * either direction makes the smoke test something other than the candidate: + * a missing pin resolves that name from the registry (the bill above), and a + * surplus pin points the smoke project at a tarball for a name npm will never + * publish. Both are reported BY NAME — "the map is incomplete" without the + * name is the diagnostic the release operator had to reverse-engineer. + * + * @param {string[]} pinned names present in the overrides map + * @param {string[]} publishable names of the publishable population + */ +export function assertPinSetTotal(pinned, publishable) { + const pinnedSet = new Set(pinned); + const publishableSet = new Set(publishable); + const missing = publishable.filter((n) => !pinnedSet.has(n)).sort(); + const surplus = pinned.filter((n) => !publishableSet.has(n)).sort(); + if (missing.length === 0 && surplus.length === 0) return; + const lines = ['tarball pin set != publishable set']; + if (missing.length > 0) { + lines.push( + ` publishable but NOT pinned (${missing.length}): ${missing.join(', ')}`, + ' → the smoke project would resolve these from the npm registry, so it', + ' would test PUBLISHED code, or die on a version not published yet.', + ); + } + if (surplus.length > 0) { + lines.push( + ` pinned but NOT publishable (${surplus.length}): ${surplus.join(', ')}`, + ' → pinning a name npm will never publish; the smoke would pass on a', + ' resolution no real user can reproduce.', + ); + } + throw new Error(lines.join('\n')); +} + async function listPublicPackages(repoRoot) { const { stdout } = await execFileP('pnpm', ['-r', 'list', '--depth', '-1', '--json'], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024, }); - const all = JSON.parse(stdout); - return all.filter((p) => p.name && p.private !== true && !EXCLUDE.has(p.name)); + return selectPublishable(JSON.parse(stdout)); } async function packOne(pkg, destDir) { @@ -89,6 +154,11 @@ async function main() { }); await Promise.all(workers); + assertPinSetTotal( + Object.keys(overrides), + packages.map((p) => p.name), + ); + const sorted = Object.fromEntries( Object.entries(overrides).sort(([a], [b]) => a.localeCompare(b)), ); @@ -97,7 +167,92 @@ async function main() { console.log(`Wrote ${Object.keys(sorted).length} override(s) → ${outPath}`); } -main().catch((err) => { - console.error(err.stack ?? String(err)); - process.exit(1); -}); +/** + * Self-test — runs without pnpm, a workspace, or a network. It pins the two + * properties the release smoke depends on, and both are ABLATION-CHECKED + * (2026-08-23): restoring `EXCLUDE = new Set(['create-objectstack'])` and + * filtering it out of `selectPublishable` turns case 1 red by name; deleting + * the `missing`/`surplus` branch of `assertPinSetTotal` turns cases 2/3 red. + * + * Case 1 is not "some package survives the filter" — it is specifically that + * an UNSCOPED public package does, because every form this defect has taken + * (a `@objectstack/*` scope glob in the pinning prose, a by-name exclusion in + * the derivation) is invisible to any fixture whose names all start with `@`. + */ +function selfTest() { + const cases = []; + const check = (name, fn) => { + try { + fn(); + cases.push(` ok — ${name}`); + } catch (err) { + cases.push(` FAIL — ${name}\n ${(err.message ?? String(err)).split('\n').join('\n ')}`); + process.exitCode = 1; + } + }; + const assert = (cond, msg) => { + if (!cond) throw new Error(msg); + }; + + check('an unscoped public package is in the derived set', () => { + const picked = selectPublishable([ + { name: '@objectstack/cli', private: false }, + { name: 'create-objectstack' }, // no `private` key at all — the real manifest + { name: '@objectstack/internal-fixtures', private: true }, + { name: undefined }, + ]).map((p) => p.name); + assert( + picked.includes('create-objectstack'), + `unscoped public package dropped from the set: ${JSON.stringify(picked)}`, + ); + assert( + !picked.includes('@objectstack/internal-fixtures'), + 'a private package leaked into the publishable set', + ); + assert(picked.length === 2, `expected 2 publishable, got ${picked.length}`); + }); + + check('set == publishable set is accepted', () => { + assertPinSetTotal(['create-objectstack', '@objectstack/cli'], ['@objectstack/cli', 'create-objectstack']); + }); + + check('a MISSING member reddens, by name', () => { + let msg = ''; + try { + assertPinSetTotal(['@objectstack/cli'], ['@objectstack/cli', 'create-objectstack']); + } catch (err) { + msg = err.message; + } + assert(msg !== '', 'a pin set missing a publishable member was accepted'); + assert( + msg.includes('create-objectstack'), + `the diagnostic does not name the missing package: ${msg}`, + ); + }); + + check('a SURPLUS member reddens, by name', () => { + let msg = ''; + try { + assertPinSetTotal(['@objectstack/cli', '@objectstack/gone'], ['@objectstack/cli']); + } catch (err) { + msg = err.message; + } + assert(msg !== '', 'a pin set with a non-publishable member was accepted'); + assert(msg.includes('@objectstack/gone'), `the diagnostic does not name the surplus package: ${msg}`); + }); + + console.log('publish-smoke-pack self-test'); + for (const line of cases) console.log(line); + console.log(process.exitCode === 1 ? 'SELF-TEST FAILED' : `SELF-TEST PASSED (${cases.length} cases)`); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + selfTest(); + } else { + main().catch((err) => { + console.error(err.stack ?? String(err)); + process.exit(1); + }); + } +} diff --git a/scripts/publish-smoke.sh b/scripts/publish-smoke.sh index 0897beb0d5..258685806a 100644 --- a/scripts/publish-smoke.sh +++ b/scripts/publish-smoke.sh @@ -460,7 +460,7 @@ if [ "$SMOKE_MODE" = "pack" ]; then # Anything NOT in the override map (transitive deps, better-auth, hono, …) # resolves from the registry exactly as it would for a real user; that # unpinned resolution is the thing under test. - log "Pinning @objectstack/* to local tarballs via project-local overrides" + log "Pinning every publishable package to local tarballs via project-local overrides" node - "$SMOKE_ROOT/tarballs/overrides.json" "$APP_DIR/pnpm-workspace.yaml" <<'EOF' const { existsSync, readFileSync, writeFileSync } = require('node:fs'); const [overridesPath, wsPath] = process.argv.slice(2); @@ -484,7 +484,8 @@ const lines = [ base, '', '# ── appended by scripts/publish-smoke.sh ─────────────────────────────────', - '# @objectstack/* pinned to the about-to-publish tarballs; everything above', + '# every publishable package pinned to its about-to-publish tarball (scoped', + '# and unscoped alike); everything above', '# is what the template ships, everything else resolves from the registry.', 'overrides:', ...Object.entries(overrides).map(([name, spec]) => ` '${name}': '${spec}'`), @@ -518,17 +519,50 @@ EOF log "Installing (pnpm, tarball-pinned)" (cd "$APP_DIR" && pnpm install --no-frozen-lockfile) - # Belt-and-braces: if any @objectstack/* resolved from the REGISTRY the + # Belt-and-braces: if any package we PINNED resolved from the REGISTRY the # override map has a hole and the smoke would silently test published code. - # Registry-resolved lockfile keys read '@objectstack/@'; - # tarball-pinned ones read '@objectstack/@file:…' (with possible - # peer suffixes containing their own @, hence the [^'@] name part). - log "Asserting no @objectstack/* leaked to the registry" - if grep -En "'@objectstack/[^'@]+@[0-9]" "$APP_DIR/pnpm-lock.yaml"; then - fail "some @objectstack/* packages resolved from the registry (see above) — publish-smoke-pack.mjs override map is incomplete" - fi - TARBALL_COUNT=$(grep -cE "'@objectstack/[^'@]+@file:" "$APP_DIR/pnpm-lock.yaml" || true) - echo " ok — $TARBALL_COUNT tarball-resolved @objectstack/* lockfile entries" + # Registry-resolved lockfile keys read '@'; tarball-pinned + # ones read '@file:…' (with possible peer suffixes containing their + # own @, hence the name-anchored match). + # + # The names come from the override map, NOT from a `@objectstack/*` glob. + # This assertion used to grep the scope, which made it blind in exactly the + # case it existed to catch: `create-objectstack` is unscoped, so when it went + # unpinned and resolved from the registry, this guard reported "ok" and the + # smoke died 200 lines later inside pnpm with ERR_PNPM_NO_MATCHING_VERSION. + # A guard whose alphabet is narrower than the set it guards is not a guard. + log "Asserting no pinned package leaked to the registry" + node - "$SMOKE_ROOT/tarballs/overrides.json" "$APP_DIR/pnpm-lock.yaml" <<'EOF' +const { readFileSync } = require('node:fs'); +const [overridesPath, lockPath] = process.argv.slice(2); +const names = Object.keys(JSON.parse(readFileSync(overridesPath, 'utf8'))); +const lock = readFileSync(lockPath, 'utf8').split(/\r?\n/); + +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const leaked = []; +let pinnedCount = 0; +for (const name of names) { + // Lockfile key lines: optional quote, the exact package name, '@', spec. + const key = new RegExp(`^\\s*'?${esc(name)}@([^']+?)'?:\\s*$`); + for (const line of lock) { + const m = key.exec(line); + if (!m) continue; + if (m[1].startsWith('file:')) pinnedCount += 1; + else if (/^[0-9]/.test(m[1])) leaked.push(`${name}@${m[1]}`); + } +} + +if (leaked.length > 0) { + console.error('::error::these PINNED packages resolved from the npm registry:'); + for (const l of leaked.sort()) console.error(` ${l}`); + console.error( + 'The publish-smoke-pack.mjs override map has a hole, so the smoke tested ' + + 'PUBLISHED code instead of the release candidate.', + ); + process.exit(1); +} +console.log(` ok — ${pinnedCount} tarball-resolved lockfile entries, 0 registry leaks (${names.length} names checked)`); +EOF else log "Scaffolding $APP_NAME with published create-objectstack@latest" (cd "$SMOKE_ROOT" && npx -y create-objectstack@latest "$APP_NAME" --skip-install --skip-skills) From c2fc2d9e70f0d01f662f9e3472dcd1692f7b3ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:37:36 +0000 Subject: [PATCH 2/2] fix(publish-smoke): de-duplicate the registry-leak diagnostic across lockfile sections A name is keyed in both `packages:` and `snapshots:`, so counting lines listed every offender twice and inflated the tarball-resolved total. Co-Authored-By: Claude --- scripts/publish-smoke.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/publish-smoke.sh b/scripts/publish-smoke.sh index 258685806a..8c5541d027 100644 --- a/scripts/publish-smoke.sh +++ b/scripts/publish-smoke.sh @@ -539,29 +539,33 @@ const names = Object.keys(JSON.parse(readFileSync(overridesPath, 'utf8'))); const lock = readFileSync(lockPath, 'utf8').split(/\r?\n/); const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -const leaked = []; -let pinnedCount = 0; +// Sets, not arrays: a name is keyed in BOTH the `packages:` and `snapshots:` +// sections, so counting lines would report every package twice. +const leaked = new Set(); +const pinned = new Set(); for (const name of names) { // Lockfile key lines: optional quote, the exact package name, '@', spec. const key = new RegExp(`^\\s*'?${esc(name)}@([^']+?)'?:\\s*$`); for (const line of lock) { const m = key.exec(line); if (!m) continue; - if (m[1].startsWith('file:')) pinnedCount += 1; - else if (/^[0-9]/.test(m[1])) leaked.push(`${name}@${m[1]}`); + if (m[1].startsWith('file:')) pinned.add(name); + else if (/^[0-9]/.test(m[1])) leaked.add(`${name}@${m[1]}`); } } -if (leaked.length > 0) { +if (leaked.size > 0) { console.error('::error::these PINNED packages resolved from the npm registry:'); - for (const l of leaked.sort()) console.error(` ${l}`); + for (const l of [...leaked].sort()) console.error(` ${l}`); console.error( 'The publish-smoke-pack.mjs override map has a hole, so the smoke tested ' + 'PUBLISHED code instead of the release candidate.', ); process.exit(1); } -console.log(` ok — ${pinnedCount} tarball-resolved lockfile entries, 0 registry leaks (${names.length} names checked)`); +console.log( + ` ok — ${pinned.size}/${names.length} pinned packages resolved from tarballs, 0 registry leaks`, +); EOF else log "Scaffolding $APP_NAME with published create-objectstack@latest"