diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7233d1253..2e2cc71ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,6 +51,13 @@ jobs:
done
node packages/blocks/bin/deco.js --help > /dev/null
+ # tests/release.test.ts: lockstep versions, the peer ranges, and what
+ # npm would pack for each package (every export's files, including
+ # the clients' helper subpaths such as @decocms/apps-vtex/image, and
+ # no test or fixture), against the dist/ built above.
+ - name: Release checks
+ run: bun x vitest run tests/release.test.ts
+
# @decocms/tanstack's smoke app, built and run on workerd (`vite preview`
# runs the Worker in Miniflare): the worker's header matrix (edge cache,
# drafts, Studio framing, CMS redirects, __cseg) against a real TanStack
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d032bafde..0e6bb044f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,10 +44,11 @@ name: Release
on:
push:
- # main releases the 8 lockstep v8 packages (@decocms/blocks + the 7
- # @decocms/apps-* clients) under blocks-v*.*.* tags (.releaserc.json).
- # v7 (@decocms/blocks 7.x, tanstack, nextjs, blocks-admin, blocks-cli,
- # apps-commerce/-blog/-website) releases from the 7.x branch, with its
+ # main releases the 10 lockstep v8 packages (@decocms/blocks, the
+ # @decocms/tanstack and @decocms/nextjs bindings, the 7 @decocms/apps-*
+ # clients) under blocks-v*.*.* tags (.releaserc.json), on npm's `latest`.
+ # v7 (@decocms/blocks 7.x, tanstack 7.x, nextjs 7.x, blocks-admin,
+ # blocks-cli, apps-commerce/-blog/-website) releases from the 7.x branch, with its
# own copy of this workflow. Publishing goes through npm Trusted Publishing
# (OIDC) — each package has this workflow configured as its trusted
# publisher on npmjs.com; the "Release" step's id-token permission lets
diff --git a/.releaserc.json b/.releaserc.json
index 0014ebfe5..abd86904d 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -62,7 +62,7 @@
"@semantic-release/exec",
{
"prepareCmd": "VERSION=${nextRelease.version} node scripts/sync-versions.mjs && bun run build",
- "publishCmd": "PACKAGES=\"blocks apps-algolia apps-magento apps-resend apps-sfmc-personalization apps-shopify apps-vtex apps-wake\"; if [ \"$DRY_RUN\" = \"true\" ]; then echo \"[dry-run] would publish version ${nextRelease.version} for: $PACKAGES\"; else failed=0; for name in $PACKAGES; do (cd \"packages/$name\" && npm publish --access public --tag ${nextRelease.channel ? nextRelease.channel : 'latest'}) || { echo \"::error::npm publish failed for packages/$name\"; failed=1; }; done; exit \"$failed\"; fi"
+ "publishCmd": "PACKAGES=\"blocks tanstack nextjs apps-algolia apps-magento apps-resend apps-sfmc-personalization apps-shopify apps-vtex apps-wake\"; if [ \"$DRY_RUN\" = \"true\" ]; then echo \"[dry-run] would publish version ${nextRelease.version} for: $PACKAGES\"; else failed=0; for name in $PACKAGES; do (cd \"packages/$name\" && npm publish --access public --tag ${nextRelease.channel ? nextRelease.channel : 'latest'}) || { echo \"::error::npm publish failed for packages/$name\"; failed=1; }; done; exit \"$failed\"; fi"
}
],
"@semantic-release/github"
diff --git a/CLAUDE.md b/CLAUDE.md
index 456a27a63..e39ce246d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,7 +58,7 @@ docs/runbooks/ alert runbooks; the provisioned alerts' runbook_
v7-only design docs, plans and skills live on the `7.x` branch. The docs for users are the docs site (`/next/*`), not this repo.
-The release allowlist in `.releaserc.json` is `blocks` plus the seven `apps-*`; the bindings join it, in lockstep, in the release PR.
+The release allowlist in `.releaserc.json` is `blocks`, `tanstack`, `nextjs` and the seven `apps-*`; nothing else is published from this line. They release in lockstep: every release publishes all ten at one version, even a package with no change (`tests/release.test.ts` holds the allowlist equal to `packages/`).
### How packages build (per-file `tsc`, no bundler)
@@ -70,7 +70,8 @@ Every package publishes compiled JavaScript so plain Node (scripts, Playwright,
- **The `source` condition** lets in-repo tools use `.ts` without a build: `tsconfig.base.json` (`customConditions`) for type-checks and `vitest.config.ts` (`resolve.conditions`) for tests, so a test importing a file by path and one importing `@decocms/blocks` share one module. A consumer that opts into `source` must do so for every environment it builds; mixing `source` and dist in one process loads both copies.
- **The bin runs dist**: `bin/deco.js` imports `dist/v8/cli/main.js` (no tsx). `tests/globalSetup.ts` rebuilds blocks' dist before the suite because the CLI conformance tests spawn the bin.
- **Two TypeScripts**: builds and type-checks use TypeScript 7 (`typescript7`, npm alias of `typescript@7`); the plain `typescript` name stays on 5.x because TypeScript 7 has no JavaScript compiler API, and `deco schema` loads the app's `typescript` peer through that API (as do Next's type-check and a few conformance tests).
-- **Release**: `.releaserc.json`'s `prepareCmd` runs `bun run build` after the version sync, and each package's `prepack` rebuilds, so `npm publish` never ships a stale or missing dist. CI (`.github/workflows/ci.yml`) type-checks, builds and imports every built export with plain Node.
+- **Release**: `.releaserc.json`'s `prepareCmd` runs `bun run build` after the version sync, and each package's `prepack` rebuilds, so `npm publish` never ships a stale or missing dist. CI (`.github/workflows/ci.yml`) type-checks, builds, imports every built export of every package with plain Node (the `apps-*` helper subpaths such as `/image` included), and runs `tests/release.test.ts`, which also checks each export's shape and that `npm pack` ships every export's files and no test or fixture.
+- **Ranges on `@decocms/blocks`**: the bindings and the `apps-*` take it as a **peer** with a caret range (`"^8.3.0"`), plus `"workspace:*"` in `devDependencies` for the workspace; never `workspace:*` in `dependencies`, which publishes as an exact pin and puts a second blocks copy next to the app on the next patch. `scripts/sync-versions.mjs` writes the release version, rewrites only `devDependencies`' `workspace:` ranges, and refuses an in-repo `dependencies` entry or a peer floor above the release. Raise the peer floor (all packages together) when a package starts using a newer blocks API.
### `@decocms/blocks` exports
diff --git a/README.md b/README.md
index 4135810f5..0a970e4c6 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
-`blocks` is a Bun workspace monorepo for Deco's CMS. This branch is the **next major (v8)**: one core package, `@decocms/blocks`, with the runtime, the `deco` CLI and the content protocol the site editor speaks; `@decocms/tanstack`, for TanStack Start sites on Cloudflare Workers; `@decocms/nextjs`, for any Next.js App Router app; and thin upstream clients, `@decocms/apps-*`. Packages export TypeScript source directly: no bundled dist layer, no duplicated runtime state.
+`blocks` is a Bun workspace monorepo for Deco's CMS. This branch is the **next major (v8)**: one core package, `@decocms/blocks`, with the runtime, the `deco` CLI and the content protocol the site editor speaks; `@decocms/tanstack`, for TanStack Start sites on Cloudflare Workers; `@decocms/nextjs`, for any Next.js App Router app; and thin upstream clients, `@decocms/apps-*`. Each package publishes JavaScript compiled file for file by `tsc` (`dist/`, with declarations), plus its TypeScript source under a `source` export condition for the workspace: nothing is bundled, so no runtime state is duplicated.
v7 (`@decocms/tanstack` 7.x, `@decocms/nextjs` 7.x, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/apps-commerce` and the rest) is maintained on the [`7.x` branch](https://github.com/decocms/blocks/tree/7.x).
@@ -50,7 +50,7 @@ v7 (`@decocms/tanstack` 7.x, `@decocms/nextjs` 7.x, `@decocms/blocks-admin`, `@d
## Packages
-All packages are versioned and released in lockstep.
+All packages are versioned and released in lockstep, starting at 8.3.0 for the bindings. The bindings and the clients take `@decocms/blocks` as a peer dependency (`^8.3.0`), so a site installs one copy of it.
| Package | Purpose |
| --- | --- |
diff --git a/bun.lock b/bun.lock
index 06717e269..b4690233d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -118,51 +118,72 @@
"packages/apps-algolia": {
"name": "@decocms/apps-algolia",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-magento": {
"name": "@decocms/apps-magento",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-resend": {
"name": "@decocms/apps-resend",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-sfmc-personalization": {
"name": "@decocms/apps-sfmc-personalization",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-shopify": {
"name": "@decocms/apps-shopify",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-vtex": {
"name": "@decocms/apps-vtex",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/apps-wake": {
"name": "@decocms/apps-wake",
"version": "0.0.0",
- "dependencies": {
+ "devDependencies": {
"@decocms/blocks": "workspace:*",
},
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0",
+ },
},
"packages/blocks": {
"name": "@decocms/blocks",
diff --git a/packages/apps-algolia/package.json b/packages/apps-algolia/package.json
index 6ebab9122..7d734f36a 100644
--- a/packages/apps-algolia/package.json
+++ b/packages/apps-algolia/package.json
@@ -29,7 +29,10 @@
"test": "vitest run --root ../.. packages/apps-algolia/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-magento/package.json b/packages/apps-magento/package.json
index e6b26bd8a..32056ae2b 100644
--- a/packages/apps-magento/package.json
+++ b/packages/apps-magento/package.json
@@ -29,7 +29,10 @@
"test": "vitest run --root ../.. packages/apps-magento/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-resend/package.json b/packages/apps-resend/package.json
index 7b640214e..1acfd27e4 100644
--- a/packages/apps-resend/package.json
+++ b/packages/apps-resend/package.json
@@ -29,7 +29,10 @@
"test": "vitest run --root ../.. packages/apps-resend/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-sfmc-personalization/package.json b/packages/apps-sfmc-personalization/package.json
index 4912b61a9..959edda43 100644
--- a/packages/apps-sfmc-personalization/package.json
+++ b/packages/apps-sfmc-personalization/package.json
@@ -29,7 +29,10 @@
"test": "vitest run --root ../.. packages/apps-sfmc-personalization/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-shopify/package.json b/packages/apps-shopify/package.json
index 719cf4dd3..27cd5d1ab 100644
--- a/packages/apps-shopify/package.json
+++ b/packages/apps-shopify/package.json
@@ -35,7 +35,10 @@
"test": "vitest run --root ../.. packages/apps-shopify/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-vtex/package.json b/packages/apps-vtex/package.json
index df0dccc5a..58fa53fa3 100644
--- a/packages/apps-vtex/package.json
+++ b/packages/apps-vtex/package.json
@@ -35,7 +35,10 @@
"test": "vitest run --root ../.. packages/apps-vtex/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/packages/apps-wake/package.json b/packages/apps-wake/package.json
index 740d5797c..20f11517e 100644
--- a/packages/apps-wake/package.json
+++ b/packages/apps-wake/package.json
@@ -39,7 +39,10 @@
"test": "vitest run --root ../.. packages/apps-wake/",
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
},
- "dependencies": {
+ "peerDependencies": {
+ "@decocms/blocks": "^8.3.0"
+ },
+ "devDependencies": {
"@decocms/blocks": "workspace:*"
},
"publishConfig": {
diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs
index 5f1e6e195..c354cadbc 100644
--- a/scripts/sync-versions.mjs
+++ b/scripts/sync-versions.mjs
@@ -1,37 +1,72 @@
#!/usr/bin/env node
-// Release-time helper: writes ${VERSION} into every packages/*/package.json,
-// rewriting "workspace:*" dependencies to the concrete version so `npm publish`
-// (run per-package, outside the workspace context) resolves correctly.
-import { readdirSync, readFileSync, writeFileSync } from "node:fs";
+// Release-time helper (.releaserc.json prepareCmd): writes ${VERSION} into
+// every packages/*/package.json, so all the packages publish in lockstep.
+//
+// Ranges between the packages:
+// - A package that builds on @decocms/blocks takes it as a peer dependency
+// with a caret range ("^8.3.0"), so a site has one blocks copy and a blocks
+// patch release never installs a second one next to an app or a binding.
+// The range is written by hand and left as is here: raise its floor when a
+// package starts using a newer blocks API.
+// - "workspace:" ranges are for the workspace only. They are allowed in
+// devDependencies (rewritten to the release version, since npm cannot read
+// them) and refused in dependencies and peerDependencies, where an exact
+// pin would reach the sites.
+//
+// PACKAGES_DIR overrides the packages folder (the release test runs this on
+// a copy of the manifests).
+import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const version = process.env.VERSION;
-if (!version) {
- console.error("VERSION env var required");
+if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
+ console.error(`VERSION env var required (a semver version), got ${JSON.stringify(version)}`);
process.exit(1);
}
-const packagesDir = join(import.meta.dirname, "..", "packages");
-const packageNames = new Set(
- readdirSync(packagesDir).map((dir) => {
- const pkg = JSON.parse(readFileSync(join(packagesDir, dir, "package.json"), "utf8"));
- return pkg.name;
- }),
+const packagesDir = process.env.PACKAGES_DIR ?? join(import.meta.dirname, "..", "packages");
+const dirs = readdirSync(packagesDir).filter((dir) =>
+ existsSync(join(packagesDir, dir, "package.json")),
);
+const manifests = dirs.map((dir) => {
+ const path = join(packagesDir, dir, "package.json");
+ return { dir, path, pkg: JSON.parse(readFileSync(path, "utf8")) };
+});
+const packageNames = new Set(manifests.map(({ pkg }) => pkg.name));
-for (const dir of readdirSync(packagesDir)) {
- const pkgPath = join(packagesDir, dir, "package.json");
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
+const [major, minor, patch] = version.split(/[.-]/).map(Number);
+const floorAbove = (range) => {
+ const m = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range);
+ if (!m) return true; // not a plain caret range: refused below
+ const [a, b, c] = m.slice(1).map(Number);
+ if (a !== major) return true;
+ return b > minor || (b === minor && c > patch);
+};
+
+const errors = [];
+for (const { dir, pkg } of manifests) {
pkg.version = version;
- for (const depField of ["dependencies", "peerDependencies", "devDependencies"]) {
- if (!pkg[depField]) continue;
- for (const dep of Object.keys(pkg[depField])) {
- if (packageNames.has(dep) && pkg[depField][dep] === "workspace:*") {
- pkg[depField][dep] = version;
+ for (const field of ["dependencies", "peerDependencies", "devDependencies"]) {
+ for (const [dep, range] of Object.entries(pkg[field] ?? {})) {
+ if (!packageNames.has(dep)) continue;
+ if (field === "devDependencies") {
+ if (range.startsWith("workspace:")) pkg[field][dep] = version;
+ continue;
+ }
+ if (field === "dependencies") {
+ errors.push(`${dir}: ${dep} must be a peer dependency, not a dependency (${range})`);
+ } else if (floorAbove(range)) {
+ errors.push(
+ `${dir}: peer ${dep} "${range}" must be a caret range in major ${major} whose floor is at most ${version}`,
+ );
}
}
}
- writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
+}
+if (errors.length) {
+ console.error(`sync-versions: refusing to release ${version}:\n ${errors.join("\n ")}`);
+ process.exit(1);
}
+for (const { path, pkg } of manifests) writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(`Synced version ${version} across ${packageNames.size} packages`);
diff --git a/tests/release.test.ts b/tests/release.test.ts
new file mode 100644
index 000000000..3aeb4f37c
--- /dev/null
+++ b/tests/release.test.ts
@@ -0,0 +1,255 @@
+// @vitest-environment node
+/**
+ * Release mechanics: every published package releases in lockstep at one
+ * version (.releaserc.json's publish allowlist, scripts/sync-versions.mjs),
+ * and every package that builds on @decocms/blocks takes it as a caret peer
+ * range, so a site installs one blocks copy and a blocks patch release never
+ * adds a second one next to an app or a binding.
+ */
+import { spawnSync } from "node:child_process";
+import {
+ cpSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+function findRepoRoot(): string {
+ let dir = process.cwd();
+ for (let i = 0; i < 8; i++) {
+ if (existsSync(join(dir, "packages", "blocks"))) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ throw new Error("could not locate repo root (packages/blocks not found above cwd)");
+}
+const repo = findRepoRoot();
+
+type Manifest = {
+ name: string;
+ version: string;
+ private?: boolean;
+ dependencies?: Record;
+ peerDependencies?: Record;
+ devDependencies?: Record;
+};
+const readManifest = (dir: string, root = join(repo, "packages")): Manifest =>
+ JSON.parse(readFileSync(join(root, dir, "package.json"), "utf8"));
+const packageDirs = readdirSync(join(repo, "packages")).filter((d) =>
+ existsSync(join(repo, "packages", d, "package.json")),
+);
+const published = packageDirs.filter((d) => !readManifest(d).private);
+
+const releaserc = JSON.parse(readFileSync(join(repo, ".releaserc.json"), "utf8"));
+const exec = releaserc.plugins.find(
+ (p: unknown) => Array.isArray(p) && p[0] === "@semantic-release/exec",
+)[1] as { prepareCmd: string; publishCmd: string };
+const allowlist = /PACKAGES=\\?"([^"\\]+)\\?"/.exec(exec.publishCmd)?.[1].split(" ") ?? [];
+
+const BLOCKS = "@decocms/blocks";
+const PEER = "^8.3.0";
+
+describe("release: lockstep", () => {
+ it("publishes every package in packages/, blocks first, then the bindings", () => {
+ expect([...allowlist].sort()).toEqual([...published].sort());
+ expect(allowlist.slice(0, 3)).toEqual(["blocks", "tanstack", "nextjs"]);
+ });
+
+ it("syncs the one release version before the build", () => {
+ expect(exec.prepareCmd).toMatch(
+ /^VERSION=\$\{nextRelease\.version\} node scripts\/sync-versions\.mjs && bun run build$/,
+ );
+ });
+
+ it("main publishes to latest; 7.x keeps its own channel (latest-7)", () => {
+ expect(releaserc.branches).toContainEqual({ name: "7.x", range: "7.x", channel: "latest-7" });
+ expect(releaserc.branches).toContain("main");
+ expect(exec.publishCmd).toMatch(
+ /--tag \$\{nextRelease\.channel \? nextRelease\.channel : 'latest'\}/,
+ );
+ expect(exec.publishCmd).toMatch(/if \[ \\?"\$DRY_RUN\\?" = \\?"true\\?" \]/);
+ });
+});
+
+describe("release: dependency ranges", () => {
+ it.each(
+ published.filter((d) => d !== "blocks"),
+ )("%s takes @decocms/blocks as a peer, ^8.3.0, and the workspace copy only for development", (dir) => {
+ const m = readManifest(dir);
+ expect(m.dependencies?.[BLOCKS]).toBeUndefined();
+ expect(m.peerDependencies?.[BLOCKS]).toBe(PEER);
+ expect(m.devDependencies?.[BLOCKS]).toBe("workspace:*");
+ });
+
+ it("no manifest pins a workspace package in dependencies or peerDependencies", () => {
+ for (const dir of packageDirs) {
+ const m = readManifest(dir);
+ for (const range of Object.values({ ...m.dependencies, ...m.peerDependencies })) {
+ expect([dir, range.startsWith("workspace:")]).toEqual([dir, false]);
+ }
+ }
+ });
+});
+
+type Leaf = { types?: string; source?: string; default?: string; sharedTypes?: true };
+/**
+ * Each export target object, through the conditional branches (`node`,
+ * `workerd`, `default`). A branch without its own `types` takes the one its
+ * parent declares for all of them (`@decocms/tanstack/scope`), listed first.
+ */
+const leaves = (value: unknown, types?: string): Leaf[] => {
+ if (typeof value !== "object" || value === null) return [];
+ const target = value as Leaf;
+ if (typeof target.default === "string") {
+ return [
+ target.types === undefined && types !== undefined
+ ? { types, ...target, sharedTypes: true }
+ : target,
+ ];
+ }
+ return Object.entries(value)
+ .filter(([key]) => key !== "types")
+ .flatMap(([, branch]) => leaves(branch, target.types ?? types));
+};
+const exportsOf = (dir: string): [string, Leaf][] =>
+ Object.entries((readManifest(dir) as Manifest & { exports: object }).exports).flatMap(
+ ([subpath, target]) => leaves(target).map((leaf): [string, Leaf] => [subpath, leaf]),
+ );
+
+/** The paths `npm pack` would put in the package's tarball (no build: `--ignore-scripts`). */
+function packedFiles(dir: string): Set {
+ const r = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
+ cwd: join(repo, "packages", dir),
+ encoding: "utf8",
+ });
+ expect(r.status, r.stderr).toBe(0);
+ const out = JSON.parse(r.stdout);
+ // npm 10 prints an array, npm 11+ an object keyed by the package name.
+ const entry = Array.isArray(out) ? out[0] : Object.values(out)[0];
+ return new Set((entry as { files: { path: string }[] }).files.map((f) => f.path));
+}
+
+// Every subpath of every published package, including the platform helpers
+// the clients ship next to their root (`@decocms/apps-vtex/image`,
+// `@decocms/apps-shopify/image`): CI imports each built target with plain
+// Node (ci.yml), and these check what npm would ship for it.
+describe("release: what each tarball ships", () => {
+ it.each(
+ published,
+ )("%s: every export is types + source + default, or plain JS shipped from src", (dir) => {
+ const subpaths = exportsOf(dir);
+ expect(subpaths.length).toBeGreaterThan(0);
+ for (const [subpath, leaf] of subpaths) {
+ const where = `${dir} ${subpath}`;
+ if (leaf.source === undefined) {
+ // `@decocms/nextjs/config`: CommonJS that next.config.js requires as is.
+ expect(Object.keys(leaf), where).toEqual(["types", "default"]);
+ expect(leaf.types, where).toMatch(/^\.\/src\/.+\.d\.c?ts$/);
+ expect(leaf.default, where).toMatch(/^\.\/src\/.+\.c?js$/);
+ continue;
+ }
+ expect(
+ Object.keys(leaf).filter((k) => k !== "sharedTypes"),
+ where,
+ ).toEqual(["types", "source", "default"]);
+ const base = leaf.source.replace(/^\.\/src\//, "").replace(/\.tsx?$/, "");
+ expect(leaf.source, where).toMatch(/^\.\/src\/.+\.tsx?$/);
+ if (leaf.sharedTypes) expect(leaf.types, where).toMatch(/^\.\/dist\/.+\.d\.ts$/);
+ else expect(leaf.types, where).toBe(`./dist/${base}.d.ts`);
+ expect(leaf.default, where).toBe(`./dist/${base}.js`);
+ }
+ });
+
+ it.each(published)("%s: the tarball holds every export's files and no test or fixture", (dir) => {
+ const files = packedFiles(dir);
+ const missing = exportsOf(dir).flatMap(([, leaf]) =>
+ [leaf.types, leaf.source, leaf.default]
+ .filter((path): path is string => path !== undefined)
+ .map((path) => path.replace(/^\.\//, ""))
+ .filter((path) => !files.has(path)),
+ );
+ // dist/ comes from `bun run build` (`bun install` runs it); release runs it before publishing.
+ expect(missing, "missing from the tarball (is dist/ built?)").toEqual([]);
+ expect(
+ [...files].filter((path) =>
+ /(\.test\.tsx?$|(^|\/)__(tests|fixtures|conformance)__\/)/.test(path),
+ ),
+ ).toEqual([]);
+ expect(files.has("package.json")).toBe(true);
+ }, 30_000);
+});
+
+describe("scripts/sync-versions.mjs", () => {
+ const dirs: string[] = [];
+ afterEach(() => {
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
+ });
+ const copyManifests = (): string => {
+ const root = mkdtempSync(join(tmpdir(), "sync-versions-"));
+ dirs.push(root);
+ for (const dir of packageDirs) {
+ mkdirSync(join(root, dir));
+ cpSync(join(repo, "packages", dir, "package.json"), join(root, dir, "package.json"));
+ }
+ return root;
+ };
+ const sync = (root: string, version: string) =>
+ spawnSync("node", [join(repo, "scripts", "sync-versions.mjs")], {
+ env: { ...process.env, VERSION: version, PACKAGES_DIR: root },
+ encoding: "utf8",
+ });
+
+ it("writes 8.3.0 everywhere, keeps the peer ranges and leaves no workspace: range", () => {
+ const root = copyManifests();
+ const r = sync(root, "8.3.0");
+ expect(r.stderr).toBe("");
+ expect(r.status).toBe(0);
+ for (const dir of packageDirs) {
+ const m = readManifest(dir, root);
+ expect(m.version).toBe("8.3.0");
+ expect(JSON.stringify(m)).not.toContain("workspace:");
+ if (dir !== "blocks") {
+ expect(m.peerDependencies?.[BLOCKS]).toBe(PEER);
+ expect(m.devDependencies?.[BLOCKS]).toBe("8.3.0");
+ }
+ }
+ });
+
+ it("keeps the peer floor on a later lockstep release", () => {
+ const root = copyManifests();
+ expect(sync(root, "8.4.1").status).toBe(0);
+ expect(readManifest("tanstack", root).peerDependencies?.[BLOCKS]).toBe(PEER);
+ });
+
+ it("refuses a release below a peer floor, which no blocks of that release could satisfy", () => {
+ const root = copyManifests();
+ const r = sync(root, "8.2.4");
+ expect(r.status).toBe(1);
+ expect(r.stderr).toContain('peer @decocms/blocks "^8.3.0"');
+ expect(readManifest("blocks", root).version).toBe("0.0.0");
+ });
+
+ it("refuses @decocms/blocks as a plain dependency (it would publish as an exact pin)", () => {
+ const root = copyManifests();
+ const path = join(root, "apps-vtex", "package.json");
+ const m = readManifest("apps-vtex", root);
+ m.dependencies = { [BLOCKS]: "workspace:*" };
+ writeFileSync(path, JSON.stringify(m));
+ const r = sync(root, "8.3.0");
+ expect(r.status).toBe(1);
+ expect(r.stderr).toContain("apps-vtex: @decocms/blocks must be a peer dependency");
+ });
+
+ it("refuses a missing or malformed VERSION", () => {
+ expect(sync(copyManifests(), "").status).toBe(1);
+ expect(sync(copyManifests(), "v8.3").status).toBe(1);
+ });
+});