Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/publish-smoke.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
177 changes: 166 additions & 11 deletions scripts/publish-smoke-pack.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,29 +16,94 @@
* 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';
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) {
Expand DownExpand Up@@ -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)),
);
Expand All@@ -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);
});
}
}
62 changes: 50 additions & 12 deletions scripts/publish-smoke.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand All@@ -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}'`),
Expand DownExpand Up@@ -518,17 +519,54 @@ 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/<name>@<version>';
# tarball-pinned ones read '@objectstack/<name>@file:…' (with possible
# peer suffixes containing their own @<version>, 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 '<name>@<version>'; tarball-pinned
# ones read '<name>@file:…' (with possible peer suffixes containing their
# own @<version>, 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, '\\$&');
// 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:')) pinned.add(name);
else if (/^[0-9]/.test(m[1])) leaked.add(`${name}@${m[1]}`);
}
}

if (leaked.size > 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 — ${pinned.size}/${names.length} pinned packages resolved from tarballs, 0 registry leaks`,
);
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)
Expand Down
Loading