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 @@ Any framework

-`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); + }); +});