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
208 changes: 208 additions & 0 deletions packages/spec/scripts/build-console-objectstack-root.test.ts
Original file line number Diff line number Diff line change
@@ -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/<name>` 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<string, string> = {}): 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([]);
});
});
1 change: 1 addition & 0 deletions packages/spec/vitest.repo-tests.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
99 changes: 99 additions & 0 deletions scripts/build-console.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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 <command>."
fi
exit 1
fi
fi

pushd "$BUILD_ROOT" > /dev/null

# objectui's root package.json may pin packages that aren't available on
Expand Down Expand Up @@ -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" \
Expand Down
Loading