diff --git a/packages/spec/scripts/build-console-objectstack-root.test.ts b/packages/spec/scripts/build-console-objectstack-root.test.ts new file mode 100644 index 00000000000..fa50a6aae70 --- /dev/null +++ b/packages/spec/scripts/build-console-objectstack-root.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins the `.objectstack-sha` branch of `scripts/build-console.sh` +// (objectstack#24306, ruling B on objectui#12955): which objectstack the console +// build's objectui install compiles against. +// +// ## The contract +// +// A pinned objectui that carries `.objectstack-sha` overrides every +// `@objectstack/*` package to `file:node_modules/.cache/objectstack/` and +// fills those directories with `scripts/objectstack-pin.mjs build`. With +// `OBJECTSTACK_ROOT` set, that script packs an installed and BUILT objectstack +// tree instead of building its own pin. build-console.sh must: +// +// 1. pass THIS framework tree as `OBJECTSTACK_ROOT`, after building the +// overridden packages here, and before objectui's frozen install; +// 2. leave a pin WITHOUT `.objectstack-sha` exactly as before (no closure +// build, no pin script), detected by the file and never by the sha; +// 3. refuse, naming the hook, a pin WITH `.objectstack-sha` whose pin script +// has no `OBJECTSTACK_ROOT` input, before any install; +// 4. build that closure WITH declarations whatever `OS_SKIP_DTS` the caller +// exported: the pin script packs whole packages and refuses one whose +// `types` entry is missing, and a typeless tree did exactly that (consumer +// pointer on objectstack#24306, measured again on this branch); +// 5. stop on the pin script's refusal, before the install. +// +// ## How it runs: the real script, a fake world +// +// The script under test is the tracked file, copied byte for byte into a temp +// framework root (it derives its root from its own path) beside a temp objectui +// git repo. Everything it would spend minutes on is a shim: `pnpm` on PATH +// records its argv and the `OS_SKIP_DTS` it received, and exits 42 at +// `install`, so "the install was reached" is the exit code itself; the pin +// script is a few lines of JS that records `OBJECTSTACK_ROOT` and its cwd. All +// records go to ONE log, so the ORDER of closure build, pin script and install +// is asserted, not inferred. The guards above this branch (the two vite hooks, +// the client and spec dists) are satisfied by the fixtures, so the branch is +// reached with nothing else to build. +// +// Vacuity guards: the case that exports OS_SKIP_DTS=1 also asserts the INSTALL +// call still received it, so "the closure build ran without it" cannot pass +// because the variable never reached the script at all. + +import { describe, it, expect, afterAll } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'build-console.sh'); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'build-console-osroot-')); +afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +const PNPM_SHIM = `#!/usr/bin/env bash +printf 'pnpm %s | OS_SKIP_DTS=%s\\n' "$*" "\${OS_SKIP_DTS-unset}" >> "$SHIM_LOG" +if [[ "\${1:-}" == "install" ]]; then exit 42; fi +exit 0 +`; + +/** A pin script that HAS the input: it reads OBJECTSTACK_ROOT and records it. */ +const PIN_WITH_HOOK = `import fs from 'node:fs'; +fs.appendFileSync(process.env.SHIM_LOG, 'pin ' + process.argv.slice(2).join(' ') + ' | OBJECTSTACK_ROOT=' + (process.env.OBJECTSTACK_ROOT ?? 'unset') + ' | cwd=' + process.cwd() + '\\n'); +process.exit(Number(process.env.FAKE_PIN_EXIT ?? '0')); +`; + +/** A pin script from before the input existed: it builds its own pin, always. */ +const PIN_WITHOUT_HOOK = `import fs from 'node:fs'; +fs.appendFileSync(process.env.SHIM_LOG, 'pin-without-hook\\n'); +`; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { + cwd, + encoding: 'utf8', + }).trim(); +} + +function write(file: string, text: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); +} + +// The objectui side: one repo, one commit per pin shape. +const UI = path.join(ROOT, 'ui'); +fs.mkdirSync(UI); +git(UI, 'init', '-q'); +write(path.join(UI, 'apps/console/vite.config.ts'), '// OBJECTSTACK_CLIENT_DIST OBJECTSTACK_SPEC_DIST\n'); +write( + path.join(UI, 'package.json'), + JSON.stringify({ + name: 'fake-objectui', + pnpm: { + overrides: { + react: '19.2.8', + '@objectstack/spec': 'file:node_modules/.cache/objectstack/spec', + '@objectstack/client': 'file:node_modules/.cache/objectstack/client', + }, + }, + }), +); +git(UI, 'add', '-A'); +git(UI, 'commit', '-q', '-m', 'a pin from before .objectstack-sha'); +const OLD_PIN = git(UI, 'rev-parse', 'HEAD'); + +write(path.join(UI, '.objectstack-sha'), `${'a'.repeat(40)}\n`); +write(path.join(UI, 'scripts/objectstack-pin.mjs'), PIN_WITHOUT_HOOK); +git(UI, 'add', '-A'); +git(UI, 'commit', '-q', '-m', '.objectstack-sha, pin script without the input'); +const NO_HOOK_PIN = git(UI, 'rev-parse', 'HEAD'); + +write(path.join(UI, 'scripts/objectstack-pin.mjs'), PIN_WITH_HOOK); +git(UI, 'add', '-A'); +git(UI, 'commit', '-q', '-m', '.objectstack-sha, pin script with OBJECTSTACK_ROOT'); +const HOOK_PIN = git(UI, 'rev-parse', 'HEAD'); + +// The framework side: the real script, and dists that satisfy the guards above +// the branch so they build nothing. +const FW = path.join(ROOT, 'fw'); +fs.mkdirSync(path.join(FW, 'scripts'), { recursive: true }); +fs.copyFileSync(SCRIPT, path.join(FW, 'scripts', 'build-console.sh')); +write(path.join(FW, 'packages/client/dist/index.d.ts'), 'export {};\n'); +write(path.join(FW, 'packages/spec/dist/index.mjs'), 'export {};\n'); +write(path.join(FW, 'packages/spec/json-schema/openapi.json'), '{}\n'); + +const BIN = path.join(ROOT, 'bin'); +write(path.join(BIN, 'pnpm'), PNPM_SHIM); +fs.chmodSync(path.join(BIN, 'pnpm'), 0o755); + +interface Run { + status: number | null; + output: string; + log: string[]; + buildRoot: string; +} + +let runs = 0; +function buildConsole(pin: string, env: Record = {}): Run { + runs += 1; + const log = path.join(ROOT, `run-${runs}.log`); + fs.writeFileSync(log, ''); + fs.writeFileSync(path.join(FW, '.objectui-sha'), `${pin}\n`); + const base = { ...process.env }; + delete base.OS_SKIP_DTS; + delete base.OBJECTSTACK_ROOT; + const result = spawnSync('bash', [path.join(FW, 'scripts', 'build-console.sh')], { + encoding: 'utf8', + env: { ...base, PATH: `${BIN}${path.delimiter}${process.env.PATH ?? ''}`, OBJECTUI_ROOT: UI, SHIM_LOG: log, ...env }, + }); + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + log: fs.readFileSync(log, 'utf8').split('\n').filter(Boolean), + buildRoot: fs.realpathSync(path.join(FW, '.cache', `objectui-${pin.slice(0, 12)}`)), + }; +} + +const closureBuilds = (log: string[]) => log.filter((l) => l.startsWith('pnpm exec turbo run build')); +const installs = (log: string[]) => log.filter((l) => l.startsWith('pnpm install')); + +describe('build-console.sh: the .objectstack-sha branch', () => { + it('a pin without .objectstack-sha builds as before: no closure build, no pin script, straight to the install', () => { + const run = buildConsole(OLD_PIN, { OS_SKIP_DTS: '1' }); + expect(run.status, run.output).toBe(42); + expect(run.log.filter((l) => l.startsWith('pin'))).toEqual([]); + expect(closureBuilds(run.log)).toEqual([]); + expect(installs(run.log)).toHaveLength(1); + }); + + it('a pin with .objectstack-sha but no OBJECTSTACK_ROOT hook refuses, naming the hook, before any install', () => { + const run = buildConsole(NO_HOOK_PIN); + expect(run.status, run.output).toBe(1); + expect(run.output).toContain('OBJECTSTACK_ROOT hook'); + expect(run.log).toEqual([]); + }); + + it('a pin with the hook builds the overridden packages, passes THIS tree as OBJECTSTACK_ROOT, then installs', () => { + const run = buildConsole(HOOK_PIN); + expect(run.status, run.output).toBe(42); + expect(run.log).toEqual([ + 'pnpm exec turbo run build --filter=@objectstack/client --filter=@objectstack/spec --output-logs=new-only | OS_SKIP_DTS=unset', + `pin build | OBJECTSTACK_ROOT=${FW} | cwd=${run.buildRoot}`, + 'pnpm install --frozen-lockfile --prefer-offline --config.production=false | OS_SKIP_DTS=unset', + ]); + }); + + it('an inherited OS_SKIP_DTS does not reach the closure build, and the script says so', () => { + const run = buildConsole(HOOK_PIN, { OS_SKIP_DTS: '1' }); + expect(run.status, run.output).toBe(42); + expect(closureBuilds(run.log)).toEqual([ + 'pnpm exec turbo run build --filter=@objectstack/client --filter=@objectstack/spec --output-logs=new-only | OS_SKIP_DTS=unset', + ]); + // Vacuity guard: the variable did reach the script — the install still sees it. + expect(installs(run.log)).toEqual([ + 'pnpm install --frozen-lockfile --prefer-offline --config.production=false | OS_SKIP_DTS=1', + ]); + expect(run.output).toContain('OS_SKIP_DTS is set'); + }); + + it("the pin script's refusal stops the build before the install", () => { + const run = buildConsole(HOOK_PIN, { FAKE_PIN_EXIT: '1' }); + expect(run.status, run.output).toBe(1); + expect(run.log.filter((l) => l.startsWith('pin build'))).toHaveLength(1); + expect(installs(run.log)).toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index bf99cc40247..733cb29301c 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -1,4 +1,5 @@ [ + "scripts/build-console-objectstack-root.test.ts", "scripts/build-schemas-check-mode.test.ts", "scripts/category-title.test.ts", "scripts/conversions-major18-merge.test.ts", diff --git a/scripts/build-console.sh b/scripts/build-console.sh index 62272092ac9..4dc0a158534 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -317,6 +317,102 @@ fi export OBJECTSTACK_SPEC_DIST="$SPEC_PKG" echo "→ Console will bundle @objectstack/spec from ${SPEC_PKG}" +# ── Install THIS framework's @objectstack/* too (objectui consumes it by commit) ── +# The two injections above answer "which spec and client does the BUNDLE carry". +# Ruling B on objectui#12955 (objectui#12957) moved the INSTALL off npm as well: a +# pinned objectui that carries `.objectstack-sha` overrides every @objectstack/* +# package it resolves to `file:node_modules/.cache/objectstack/` in its root +# package.json, and its `scripts/objectstack-pin.mjs build` fills those directories +# from a build of the objectstack commit `.objectstack-sha` names. Until something +# fills them, the frozen install below dies (measured at objectui 749ed760: exit +# 254, `ENOENT ... scandir node_modules/.cache/objectstack/client`); and the commit +# objectui would fill them from is its own pin, not this tree. +# +# That script's OBJECTSTACK_ROOT input packs the same packages from an installed +# and BUILT objectstack tree instead, with no lockfile change. Pointed at THIS tree, +# every @objectstack/* package objectui installs is this framework's, the same +# answer the injections give for the bundle. The injections stay: a pin without +# `.objectstack-sha` still installs from npm and needs them. +# +# Detected by the FILE, never by the sha: a pin without `.objectstack-sha` builds +# exactly as before. A pin with it but without the hook refuses, like the two +# vite.config.ts guards above, rather than letting objectui build its own pin. +OBJECTSTACK_PIN_SCRIPT="${BUILD_ROOT}/scripts/objectstack-pin.mjs" +if [[ -f "${BUILD_ROOT}/.objectstack-sha" ]]; then + if [[ ! -f "$OBJECTSTACK_PIN_SCRIPT" ]] || ! grep -q "OBJECTSTACK_ROOT" "$OBJECTSTACK_PIN_SCRIPT"; then + echo "✗ objectui@${PINNED_SHA:0:12} carries .objectstack-sha but has no OBJECTSTACK_ROOT hook in" + echo " scripts/objectstack-pin.mjs — its install would resolve @objectstack/* from objectui's own" + echo " build of the objectstack commit it pins, not from this framework." + echo " Bump .objectui-sha to a commit that includes the hook." + exit 1 + fi + + # Build what the pin script packs, through turbo (see the client's note above): + # the packages objectui overrides, read from its root package.json at the pin + # (the same list the pin script reads), and their dependencies by `^build`. + # Unguarded on purpose: the pin script packs whatever dist/ is on disk, so a + # stale one would install without a word, while turbo judges freshness by + # content hash. Every workflow that runs this script has just built that + # closure, so there this is a cache hit; `new-only` keeps a hit from replaying + # thousands of cached log lines, and still prints every task that really runs. + # + # WITH declarations, whatever OS_SKIP_DTS the caller exported. The two guards + # above accept a typeless client and spec because the vite hooks read only the + # `import` condition; the pin script packs whole packages as they publish and + # refuses one whose `types` entry is missing. Measured on this branch with + # OS_SKIP_DTS=1 and a typeless tree: exit 1, "@objectstack/client is not built + # ... dist/index.d.ts, which is not there", and the rebuild that refusal + # prints inherits the same OS_SKIP_DTS (consumer pointer on objectstack#24306). + # turbo.json lists OS_SKIP_DTS in globalEnv, so this build hashes apart from a + # typeless one and cannot replay it. + OBJECTSTACK_PINNED_NAMES="$(node -e ' + const manifest = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + const names = Object.keys(manifest.pnpm?.overrides ?? {}).filter((n) => n.startsWith("@objectstack/")); + process.stdout.write(names.sort().join(" ")); + ' "${BUILD_ROOT}/package.json")" + OBJECTSTACK_TURBO_FILTERS=() + for name in $OBJECTSTACK_PINNED_NAMES; do + OBJECTSTACK_TURBO_FILTERS+=("--filter=${name}") + done + if (( ${#OBJECTSTACK_TURBO_FILTERS[@]} > 0 )); then + echo "→ objectui@${PINNED_SHA:0:12} installs ${OBJECTSTACK_PINNED_NAMES} by commit — building them here..." + if [[ -n "${OS_SKIP_DTS:-}" ]]; then + echo " OS_SKIP_DTS is set; these packages are built WITHOUT it, declarations included," + echo " because objectui packs them as they publish." + fi + (cd "$FRAMEWORK_ROOT" && unset OS_SKIP_DTS && pnpm exec turbo run build "${OBJECTSTACK_TURBO_FILTERS[@]}" --output-logs=new-only) + fi + + # The pin script refuses (exit 1, reason on stderr) on a missing entry point, + # and on two dependency mismatches between these packages and objectui's pin: + # a packed manifest whose dependency NAMES differ from what objectui's lockfile + # records (a frozen install would link the package without them and exit 0), + # and an @objectstack/* dependency objectui's root package.json does not + # override. This tree trips either one by itself, the first time it adds or + # drops a runtime dependency of one of these packages after the objectstack + # commit objectui's `.objectstack-sha` names, and the fix lives in objectui. + echo "→ Packing them into objectui's install from ${FRAMEWORK_ROOT} (OBJECTSTACK_ROOT)" + if ! (cd "$BUILD_ROOT" && OBJECTSTACK_ROOT="$FRAMEWORK_ROOT" node scripts/objectstack-pin.mjs build); then + echo "✗ objectui@${PINNED_SHA:0:12}'s scripts/objectstack-pin.mjs refused to stand this tree in for" + echo " the objectstack commit its .objectstack-sha names (the reason is above)." + echo " If the reason names a dependency (pnpm-lock.yaml does not record what the build" + echo " declares, or an @objectstack/* package the root package.json does not override): this" + echo " tree added or dropped a runtime dependency of a package objectui installs from it, after" + echo " the objectstack commit objectui pins. objectui has to learn it first, in objectui: move" + echo " its .objectstack-sha to an objectstack commit that carries the change" + echo " (scripts/bump-objectstack.sh there), commit the pnpm-lock.yaml (and any override) that" + echo " move needs, land it, then move .objectui-sha here to that objectui commit." + echo " ⛔ Not here: editing ${BUILD_ROOT}/pnpm-lock.yaml or making the install below non-frozen" + echo " builds a console against a dependency set no objectui commit records." + if [[ -n "${OS_SKIP_DTS:-}" ]]; then + echo " OS_SKIP_DTS is set in this environment. The build above ran without it; a rebuild by" + echo " hand, such as the one the pin script prints, inherits it and comes out typeless again:" + echo " run it as env -u OS_SKIP_DTS ." + fi + exit 1 + fi +fi + pushd "$BUILD_ROOT" > /dev/null # objectui's root package.json may pin packages that aren't available on @@ -387,6 +483,9 @@ fi # one-sided "is the new text present" probe pass even with no injection at all. # --objectui is the checkout just built: objectui's own registry mirrors spec # descriptions, so text its tracked source carries is not evidence of either spec. +# On a pin that installs @objectstack/* from this tree (the OBJECTSTACK_ROOT block +# above), --vendored IS this tree's spec, packed: the script then reports no +# skew, because objectui's install no longer holds a published spec to leak. node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \ --injected "$SPEC_PKG" \ --vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \