From 7e0bae1cbbb3190c578c21092c962a82a9e14fa0 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:36:21 -0400 Subject: [PATCH] Publish navbake with the lockstep set and gate the release list against manifests @jgengine/editor@0.18.1 depends on @jgengine/navbake, which no publish list included, so bun install on the released SDK fails. navbake joins the publish workflow, set-version, release bullet, and README set. check-release-set reads the three lists plus package manifests and fails on any unpublished or out-of-order workspace dependency; it runs in gate, check-types, and publish. Closes #1752 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/publish.yml | 5 +- CHANGELOG.md | 2 + README.md | 2 +- package.json | 1 + scripts/check-release-set.test.ts | 35 +++++++++ scripts/check-release-set.ts | 116 ++++++++++++++++++++++++++++++ scripts/release.ts | 2 +- scripts/run-stages.ts | 1 + scripts/set-version.ts | 2 +- 9 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 scripts/check-release-set.test.ts create mode 100644 scripts/check-release-set.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b5fa31d..35e34344 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,10 +38,11 @@ jobs: restore-keys: bun-${{ runner.os }}- - run: bun install --frozen-lockfile - name: Stage the lockstep changelog into each package - run: for p in core rapier ws sql react convex node shell editor assets github jgengine; do cp CHANGELOG.md "packages/$p/CHANGELOG.md"; done + run: for p in core rapier ws sql navbake react convex node shell editor assets github jgengine; do cp CHANGELOG.md "packages/$p/CHANGELOG.md"; done - name: Stage per-package skills run: bun run stage-skills - run: bun run check-artifacts + - run: bun run check-release-set - run: bun run build - run: bun run check-types:sdk - run: bun test packages @@ -49,7 +50,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - for p in core rapier ws sql react convex node shell editor assets github jgengine; do + for p in core rapier ws sql navbake react convex node shell editor assets github jgengine; do name=$(node -p "require('./packages/$p/package.json').name") version=$(node -p "require('./packages/$p/package.json').version") case "$version" in diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a83576..c1d22db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ between (`--json` for structured output). ### Fixed +- `@jgengine/navbake` joins the published lockstep set. `@jgengine/editor@0.18.1` depended on it while nothing published it, so `bun install` on the released SDK failed. `check-release-set` (in `gate`, `check-types`, and the publish workflow) now fails when a published package depends on a workspace package outside the publish list or out of dependency order, or when the publish, version-bump, and changelog package lists disagree. + - `pickModel` / `resolveModelPlan` warn once in dev when a `ModelPick.fallbackModel` is not in the asset catalog (a typo or an undeclared pack), instead of silently shipping a fallback that can never take over. - Volumetric clouds no longer cut through distant buildings: the proxy dome writes per-fragment depth at the raymarch's first cloud hit, so geometry between the camera and the cloud slab occludes the clouds instead of the dome radius deciding. `STUDIO_STAGE_POST` ambient occlusion is softer (radius 1.1, intensity 1.5) so tower silhouettes stop streaking. diff --git a/README.md b/README.md index 1b19fbfc..4c125d9f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Packages -**Versions:** the lockstep game SDK set is `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}` (currently **0.18.x** — bump together). Separate cadences: CLI package `jgengine` and `@jgengine/github` (may lag; not part of that lockstep set). +**Versions:** the lockstep game SDK set is `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}` (currently **0.18.x** — bump together). Separate cadences: CLI package `jgengine` and `@jgengine/github` (may lag; not part of that lockstep set). | Package | What it is | | --- | --- | diff --git a/package.json b/package.json index 3ac00b9a..f5b8029e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build": "bun scripts/guard.ts 600 'bun scripts/ensure-ready.ts --install-only && bun run --cwd packages/core build && bun run --cwd packages/rapier build && bun run --cwd packages/ws build && bun run --cwd packages/sql build && bun run --cwd packages/navbake build && bun run --cwd packages/react build && bun run --cwd packages/convex build && bun run --cwd packages/node build && bun run --cwd packages/shell build && bun run --cwd packages/editor build && bun run --cwd packages/assets build && bun run --cwd packages/github build && bun run --cwd packages/jgengine build && bun run build:registry'", "deploy:cloudflare": "bun --cwd=apps/web run deploy:cloudflare", "check-artifacts": "bun scripts/check-no-src-artifacts.ts", + "check-release-set": "bun scripts/check-release-set.ts", "check-changelog": "bun scripts/check-changelog.ts", "release": "bun scripts/release.ts", "version:prerelease": "bun scripts/set-version.ts", diff --git a/scripts/check-release-set.test.ts b/scripts/check-release-set.test.ts new file mode 100644 index 00000000..73717b1f --- /dev/null +++ b/scripts/check-release-set.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; + +import { checkPublishOrder, checkReleaseSet, lockstepSetFrom, publishListsFrom, versionPackagesFrom } from "./check-release-set"; + +describe("check-release-set", () => { + const manifests = new Map([ + ["core", { name: "@jgengine/core" }], + ["navbake", { name: "@jgengine/navbake", dependencies: { "@jgengine/core": "^0.18.0" } }], + ["editor", { name: "@jgengine/editor", dependencies: { "@jgengine/core": "^0.18.0", "@jgengine/navbake": "^0.18.0" } }], + ]); + + test("flags a dependency that is not published", () => { + expect(checkPublishOrder(["core", "editor"], manifests)).toEqual([ + "@jgengine/editor depends on @jgengine/navbake (packages/navbake), which is not in the publish list — consumers cannot install it", + ]); + }); + + test("flags a dependency published after its dependent", () => { + expect(checkPublishOrder(["core", "editor", "navbake"], manifests)).toEqual(["@jgengine/navbake must publish before @jgengine/editor; it is listed after"]); + }); + + test("passes a complete order", () => { + expect(checkPublishOrder(["core", "navbake", "editor"], manifests)).toEqual([]); + }); + + test("parses the three list sources", () => { + expect(publishListsFrom('for p in core navbake; do\nfor p in core navbake; do')).toEqual([["core", "navbake"], ["core", "navbake"]]); + expect(versionPackagesFrom('const PACKAGES = ["core", "navbake"];')).toEqual(["core", "navbake"]); + expect(lockstepSetFrom("`@jgengine/{core,navbake}`")).toEqual(["core", "navbake"]); + }); + + test("the repository's own lists agree", () => { + expect(checkReleaseSet().failures).toEqual([]); + }); +}); diff --git a/scripts/check-release-set.ts b/scripts/check-release-set.ts new file mode 100644 index 00000000..c821b29a --- /dev/null +++ b/scripts/check-release-set.ts @@ -0,0 +1,116 @@ +/** + * Every `@jgengine/*` package a published package depends on must itself be published, and earlier + * in the publish order. `@jgengine/editor@0.18.1` shipped depending on `@jgengine/navbake`, which no + * list included, so no external consumer could install the SDK (#1752). The publish workflow, the + * version bumper, and the release changelog bullet each carry their own copy of the set; this check + * reads all three and the package manifests so they cannot drift apart again. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const read = (rel: string) => readFileSync(`${root}${rel}`, "utf8"); + +export interface ReleaseSetReport { + publishOrder: string[]; + failures: string[]; +} + +/** The `for p in …; do` package lists in the publish workflow; every list must agree. */ +export function publishListsFrom(workflow: string): string[][] { + return [...workflow.matchAll(/for p in ([a-z0-9 ]+); do/g)].map((match) => match[1]!.trim().split(/\s+/)); +} + +/** `const PACKAGES = [...]` in set-version.ts. */ +export function versionPackagesFrom(source: string): string[] { + const match = /const PACKAGES = \[([^\]]+)\]/.exec(source); + if (!match) throw new Error("set-version.ts has no `const PACKAGES = [...]`"); + return [...match[1]!.matchAll(/"([a-z0-9]+)"/g)].map((entry) => entry[1]!); +} + +/** The `@jgengine/{a,b,c}` set named in the release lockstep bullet. */ +export function lockstepSetFrom(source: string): string[] { + const match = /@jgengine\/\{([a-z0-9,]+)\}/.exec(source); + if (!match) throw new Error("no `@jgengine/{...}` lockstep set found"); + return match[1]!.split(","); +} + +/** + * Checks one publish order against the manifests: every workspace dependency of a listed package is + * listed, and listed before its dependent. `manifests` maps directory name to its parsed package.json. + */ +export function checkPublishOrder( + publishOrder: readonly string[], + manifests: ReadonlyMap; peerDependencies?: Record }>, +): string[] { + const failures: string[] = []; + const dirByName = new Map([...manifests].map(([dir, manifest]) => [manifest.name, dir])); + const position = new Map(publishOrder.map((dir, index) => [dir, index])); + for (const dir of publishOrder) { + const manifest = manifests.get(dir); + if (manifest === undefined) { + failures.push(`publish list names "${dir}" but packages/${dir}/package.json does not exist`); + continue; + } + for (const dep of Object.keys(manifest.dependencies ?? {})) { + if (!dep.startsWith("@jgengine/")) continue; + const depDir = dirByName.get(dep); + if (depDir === undefined) { + failures.push(`${manifest.name} depends on ${dep}, which is not a workspace package`); + continue; + } + const depIndex = position.get(depDir); + if (depIndex === undefined) { + failures.push(`${manifest.name} depends on ${dep} (packages/${depDir}), which is not in the publish list — consumers cannot install it`); + } else if (depIndex > position.get(dir)!) { + failures.push(`${dep} must publish before ${manifest.name}; it is listed after`); + } + } + } + return failures; +} + +export function checkReleaseSet(): ReleaseSetReport { + const failures: string[] = []; + const lists = publishListsFrom(read(".github/workflows/publish.yml")); + if (lists.length === 0) failures.push("publish.yml has no `for p in …` package list"); + const publishOrder = lists[0] ?? []; + for (const list of lists.slice(1)) { + if (list.join(" ") !== publishOrder.join(" ")) failures.push(`publish.yml package lists disagree: "${publishOrder.join(" ")}" vs "${list.join(" ")}"`); + } + const versioned = versionPackagesFrom(read("scripts/set-version.ts")); + const missingFromVersion = publishOrder.filter((dir) => !versioned.includes(dir)); + const extraInVersion = versioned.filter((dir) => !publishOrder.includes(dir)); + if (missingFromVersion.length > 0) failures.push(`set-version.ts PACKAGES lacks published package(s): ${missingFromVersion.join(", ")}`); + if (extraInVersion.length > 0) failures.push(`set-version.ts PACKAGES bumps unpublished package(s): ${extraInVersion.join(", ")}`); + + const manifests = new Map }>(); + for (const dir of readdirSync(`${root}packages`)) { + const path = `packages/${dir}/package.json`; + if (existsSync(`${root}${path}`)) manifests.set(dir, JSON.parse(read(path))); + } + failures.push(...checkPublishOrder(publishOrder, manifests)); + + const separateCadence = new Set(["jgengine", "github"]); + const lockstepExpected = publishOrder.filter((dir) => !separateCadence.has(dir)).sort(); + for (const [label, source] of [ + ["scripts/release.ts", read("scripts/release.ts")], + ["README.md", read("README.md")], + ] as const) { + const named = lockstepSetFrom(source).sort(); + if (named.join(",") !== lockstepExpected.join(",")) { + failures.push(`${label} lockstep set is {${named.join(",")}} but the publish list implies {${lockstepExpected.join(",")}}`); + } + } + return { publishOrder, failures }; +} + +if (import.meta.main) { + const report = checkReleaseSet(); + if (report.failures.length > 0) { + console.error("check-release-set failed:"); + for (const failure of report.failures) console.error(` - ${failure}`); + process.exit(1); + } + console.log(`check-release-set ok: ${report.publishOrder.length} packages publish in dependency order (${report.publishOrder.join(" ")})`); +} diff --git a/scripts/release.ts b/scripts/release.ts index c0247d45..ddba8a07 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -93,7 +93,7 @@ export function plainText(text: string): string { export function lockstepBullet(sdk: string, cli: string, github: string): string { return ( `**Bump lockstep SDK packages to \`^${sdk}\`:** ` + - `\`@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets}\`. ` + + `\`@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}\`. ` + `CLI \`jgengine\` is \`${cli}\`; \`@jgengine/github\` is \`${github}\`.` ); } diff --git a/scripts/run-stages.ts b/scripts/run-stages.ts index 4992d09a..8455354c 100644 --- a/scripts/run-stages.ts +++ b/scripts/run-stages.ts @@ -52,6 +52,7 @@ const CHECK_TYPES_STAGES: readonly Stage[] = [ { name: "ensure-ready", ...bun("scripts/ensure-ready.ts"), rerun: "bun scripts/ensure-ready.ts" }, ...[ "check-artifacts", + "check-release-set", "check-skills", "check-skill-api", "check-orphan-ratchet", diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 2cc84f2e..790d01eb 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -const PACKAGES = ["core", "rapier", "ws", "sql", "react", "convex", "node", "shell", "editor", "assets", "github", "jgengine"]; +const PACKAGES = ["core", "rapier", "ws", "sql", "navbake", "react", "convex", "node", "shell", "editor", "assets", "github", "jgengine"]; const root = fileURLToPath(new URL("..", import.meta.url)); const args = process.argv.slice(2); const check = args.includes("--check");