1616 * Packing everything keeps the overrides map total — a package missing from
1717 * it would make the smoke project resolve that name from the npm registry,
1818 * silently testing a published version instead of the candidate one.
19+ *
20+ * THE SET IS THE PUBLISHABLE POPULATION — never a scope glob, never a hand
21+ * list, never an exclusion. This script used to carve `create-objectstack`
22+ * out by name, on the rationale that "no @objectstack/* manifest depends on
23+ * it". That rationale expired the day `@objectstack/cli` took a dependency on
24+ * the scaffolder: cli@17.2.0 declared `create-objectstack@17.2.0`, the name
25+ * was neither packed nor pinned, pnpm fell back to the registry, and the
26+ * release candidate's own smoke died on ERR_PNPM_NO_MATCHING_VERSION for a
27+ * version that by definition does not exist yet — a chicken-and-egg red on
28+ * every release candidate from that day on. The general shape of that bill:
29+ * whether a workspace package is *reachable* from some other manifest is a
30+ * fact about the dependency graph AT ONE MOMENT, and it is not the question
31+ * this script gets to ask. Publishable is the question, `private !== true`
32+ * is the answer, and `assertPinSetTotal` below re-checks it on every run so
33+ * a future exclusion cannot re-open the hole silently.
34+ *
35+ * Source of truth: the workspace itself. The Changesets `fixed` group in
36+ * .changeset/config.json enumerates the same 69 names, but it is a DERIVED
37+ * declaration validated against the workspace by scripts/check-changeset-fixed.mjs
38+ * (which reddens both when a public package is missing from the group and
39+ * when a group name no longer exists) — deriving from the group would mean
40+ * reading a copy that a gate keeps honest, rather than the thing itself.
1941 */
2042
2143import { execFile } from 'node:child_process' ;
2244import { mkdirSync , writeFileSync } from 'node:fs' ;
2345import { resolve } from 'node:path' ;
2446import { promisify } from 'node:util' ;
2547
26- const execFileP = promisify ( execFile ) ;
48+ import { isEntrypoint } from './invoked-as.mjs' ;
2749
28- // Not consumed as npm dependencies by a scaffolded project:
29- // create-objectstack — the scaffolder itself; the smoke runs it straight
30- // from the repo's built bin, and no @objectstack/* manifest depends on it.
31- const EXCLUDE = new Set ( [ 'create-objectstack' ] ) ;
50+ const execFileP = promisify ( execFile ) ;
3251
3352const CONCURRENCY = 8 ;
3453
54+ /**
55+ * The publishable population: every workspace member npm would receive.
56+ * No scope filter — `create-objectstack` is unscoped and publishable, and
57+ * the next unscoped public package must land in the set on its own.
58+ *
59+ * @param {{name?: string, private?: boolean}[] } all workspace members
60+ * @returns {{name: string}[] }
61+ */
62+ export function selectPublishable ( all ) {
63+ return all . filter ( ( p ) => p . name && p . private !== true ) ;
64+ }
65+
66+ /**
67+ * The pin set MUST equal the publishable set, both directions. A hole in
68+ * either direction makes the smoke test something other than the candidate:
69+ * a missing pin resolves that name from the registry (the bill above), and a
70+ * surplus pin points the smoke project at a tarball for a name npm will never
71+ * publish. Both are reported BY NAME — "the map is incomplete" without the
72+ * name is the diagnostic the release operator had to reverse-engineer.
73+ *
74+ * @param {string[] } pinned names present in the overrides map
75+ * @param {string[] } publishable names of the publishable population
76+ */
77+ export function assertPinSetTotal ( pinned , publishable ) {
78+ const pinnedSet = new Set ( pinned ) ;
79+ const publishableSet = new Set ( publishable ) ;
80+ const missing = publishable . filter ( ( n ) => ! pinnedSet . has ( n ) ) . sort ( ) ;
81+ const surplus = pinned . filter ( ( n ) => ! publishableSet . has ( n ) ) . sort ( ) ;
82+ if ( missing . length === 0 && surplus . length === 0 ) return ;
83+ const lines = [ 'tarball pin set != publishable set' ] ;
84+ if ( missing . length > 0 ) {
85+ lines . push (
86+ ` publishable but NOT pinned (${ missing . length } ): ${ missing . join ( ', ' ) } ` ,
87+ ' → the smoke project would resolve these from the npm registry, so it' ,
88+ ' would test PUBLISHED code, or die on a version not published yet.' ,
89+ ) ;
90+ }
91+ if ( surplus . length > 0 ) {
92+ lines . push (
93+ ` pinned but NOT publishable (${ surplus . length } ): ${ surplus . join ( ', ' ) } ` ,
94+ ' → pinning a name npm will never publish; the smoke would pass on a' ,
95+ ' resolution no real user can reproduce.' ,
96+ ) ;
97+ }
98+ throw new Error ( lines . join ( '\n' ) ) ;
99+ }
100+
35101async function listPublicPackages ( repoRoot ) {
36102 const { stdout } = await execFileP ( 'pnpm' , [ '-r' , 'list' , '--depth' , '-1' , '--json' ] , {
37103 cwd : repoRoot ,
38104 maxBuffer : 64 * 1024 * 1024 ,
39105 } ) ;
40- const all = JSON . parse ( stdout ) ;
41- return all . filter ( ( p ) => p . name && p . private !== true && ! EXCLUDE . has ( p . name ) ) ;
106+ return selectPublishable ( JSON . parse ( stdout ) ) ;
42107}
43108
44109async function packOne ( pkg , destDir ) {
@@ -89,6 +154,11 @@ async function main() {
89154 } ) ;
90155 await Promise . all ( workers ) ;
91156
157+ assertPinSetTotal (
158+ Object . keys ( overrides ) ,
159+ packages . map ( ( p ) => p . name ) ,
160+ ) ;
161+
92162 const sorted = Object . fromEntries (
93163 Object . entries ( overrides ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) ) ,
94164 ) ;
@@ -97,7 +167,92 @@ async function main() {
97167 console . log ( `Wrote ${ Object . keys ( sorted ) . length } override(s) → ${ outPath } ` ) ;
98168}
99169
100- main ( ) . catch ( ( err ) => {
101- console . error ( err . stack ?? String ( err ) ) ;
102- process . exit ( 1 ) ;
103- } ) ;
170+ /**
171+ * Self-test — runs without pnpm, a workspace, or a network. It pins the two
172+ * properties the release smoke depends on, and both are ABLATION-CHECKED
173+ * (2026-08-23): restoring `EXCLUDE = new Set(['create-objectstack'])` and
174+ * filtering it out of `selectPublishable` turns case 1 red by name; deleting
175+ * the `missing`/`surplus` branch of `assertPinSetTotal` turns cases 2/3 red.
176+ *
177+ * Case 1 is not "some package survives the filter" — it is specifically that
178+ * an UNSCOPED public package does, because every form this defect has taken
179+ * (a `@objectstack/*` scope glob in the pinning prose, a by-name exclusion in
180+ * the derivation) is invisible to any fixture whose names all start with `@`.
181+ */
182+ function selfTest ( ) {
183+ const cases = [ ] ;
184+ const check = ( name , fn ) => {
185+ try {
186+ fn ( ) ;
187+ cases . push ( ` ok — ${ name } ` ) ;
188+ } catch ( err ) {
189+ cases . push ( ` FAIL — ${ name } \n ${ ( err . message ?? String ( err ) ) . split ( '\n' ) . join ( '\n ' ) } ` ) ;
190+ process . exitCode = 1 ;
191+ }
192+ } ;
193+ const assert = ( cond , msg ) => {
194+ if ( ! cond ) throw new Error ( msg ) ;
195+ } ;
196+
197+ check ( 'an unscoped public package is in the derived set' , ( ) => {
198+ const picked = selectPublishable ( [
199+ { name : '@objectstack/cli' , private : false } ,
200+ { name : 'create-objectstack' } , // no `private` key at all — the real manifest
201+ { name : '@objectstack/internal-fixtures' , private : true } ,
202+ { name : undefined } ,
203+ ] ) . map ( ( p ) => p . name ) ;
204+ assert (
205+ picked . includes ( 'create-objectstack' ) ,
206+ `unscoped public package dropped from the set: ${ JSON . stringify ( picked ) } ` ,
207+ ) ;
208+ assert (
209+ ! picked . includes ( '@objectstack/internal-fixtures' ) ,
210+ 'a private package leaked into the publishable set' ,
211+ ) ;
212+ assert ( picked . length === 2 , `expected 2 publishable, got ${ picked . length } ` ) ;
213+ } ) ;
214+
215+ check ( 'set == publishable set is accepted' , ( ) => {
216+ assertPinSetTotal ( [ 'create-objectstack' , '@objectstack/cli' ] , [ '@objectstack/cli' , 'create-objectstack' ] ) ;
217+ } ) ;
218+
219+ check ( 'a MISSING member reddens, by name' , ( ) => {
220+ let msg = '' ;
221+ try {
222+ assertPinSetTotal ( [ '@objectstack/cli' ] , [ '@objectstack/cli' , 'create-objectstack' ] ) ;
223+ } catch ( err ) {
224+ msg = err . message ;
225+ }
226+ assert ( msg !== '' , 'a pin set missing a publishable member was accepted' ) ;
227+ assert (
228+ msg . includes ( 'create-objectstack' ) ,
229+ `the diagnostic does not name the missing package: ${ msg } ` ,
230+ ) ;
231+ } ) ;
232+
233+ check ( 'a SURPLUS member reddens, by name' , ( ) => {
234+ let msg = '' ;
235+ try {
236+ assertPinSetTotal ( [ '@objectstack/cli' , '@objectstack/gone' ] , [ '@objectstack/cli' ] ) ;
237+ } catch ( err ) {
238+ msg = err . message ;
239+ }
240+ assert ( msg !== '' , 'a pin set with a non-publishable member was accepted' ) ;
241+ assert ( msg . includes ( '@objectstack/gone' ) , `the diagnostic does not name the surplus package: ${ msg } ` ) ;
242+ } ) ;
243+
244+ console . log ( 'publish-smoke-pack self-test' ) ;
245+ for ( const line of cases ) console . log ( line ) ;
246+ console . log ( process . exitCode === 1 ? 'SELF-TEST FAILED' : `SELF-TEST PASSED (${ cases . length } cases)` ) ;
247+ }
248+
249+ if ( isEntrypoint ( import . meta. url ) ) {
250+ if ( process . argv . includes ( '--self-test' ) ) {
251+ selfTest ( ) ;
252+ } else {
253+ main ( ) . catch ( ( err ) => {
254+ console . error ( err . stack ?? String ( err ) ) ;
255+ process . exit ( 1 ) ;
256+ } ) ;
257+ }
258+ }
0 commit comments