From 02ae5c6dd98e2ad92621dcac8c0637113a8b808d Mon Sep 17 00:00:00 2001 From: Mark Learst Date: Sun, 16 Aug 2026 10:05:36 -0400 Subject: [PATCH 1/4] feat: add unscoped Primitree launcher --- .changeset/README.md | 8 +- .changeset/config.json | 9 +- .changeset/pre.json | 13 ++ .changeset/primitree-one.md | 21 +++ .github/workflows/ci.yml | 6 +- README.md | 8 +- apps/docs/content/docs/cli/check.mdx | 2 +- apps/docs/content/docs/cli/index.mdx | 2 +- .../content/docs/getting-started/index.mdx | 2 +- apps/docs/content/docs/hooks/index.mdx | 2 +- apps/docs/content/docs/hooks/local-tokens.mdx | 2 +- apps/docs/content/docs/hooks/migration.mdx | 2 +- apps/docs/content/docs/index.mdx | 2 +- apps/docs/content/docs/mcp/index.mdx | 2 +- docs/launch/announcement.md | 12 +- docs/launch/v1.0.0-next.0.md | 47 ++++++ docs/releasing.md | 147 +++++++++------- package.json | 4 +- packages/cli/CHANGELOG.md | 23 ++- packages/cli/README.md | 6 +- packages/cli/package.json | 3 +- packages/cli/tests/launcher-package.test.ts | 40 +++++ packages/cli/tests/public-copy.test.ts | 1 + packages/core/CHANGELOG.md | 17 +- packages/core/README.md | 4 +- packages/core/package.json | 2 +- packages/dtcg/CHANGELOG.md | 22 ++- packages/dtcg/README.md | 4 +- packages/dtcg/package.json | 2 +- packages/hooks/CHANGELOG.md | 25 ++- packages/hooks/README.md | 6 +- packages/hooks/package.json | 2 +- packages/mcp/CHANGELOG.md | 23 ++- packages/mcp/README.md | 2 +- packages/mcp/package.json | 2 +- packages/primitree/CHANGELOG.md | 25 +++ packages/primitree/LICENSE | 21 +++ packages/primitree/README.md | 21 +++ packages/primitree/bin/primitree.js | 2 + packages/primitree/package.json | 44 +++++ pnpm-lock.yaml | 14 +- scripts/brand-rules.mjs | 1 + scripts/check-brand.test.mjs | 4 + scripts/check-prose.test.mjs | 6 +- scripts/check-release.mjs | 7 +- scripts/check-release.test.mjs | 142 ++++++++++------ scripts/github-release.mjs | 30 ++-- scripts/github-release.test.mjs | 76 +++++++-- scripts/prerelease-contract.test.mjs | 81 +++++++++ scripts/release-artifacts.mjs | 27 ++- scripts/release-artifacts.test.mjs | 47 ++++-- scripts/release-config.mjs | 35 ++++ scripts/release-publish.mjs | 80 +++++++-- scripts/release-publish.test.mjs | 157 +++++++++++++++--- 54 files changed, 1054 insertions(+), 241 deletions(-) create mode 100644 .changeset/pre.json create mode 100644 .changeset/primitree-one.md create mode 100644 docs/launch/v1.0.0-next.0.md create mode 100644 packages/cli/tests/launcher-package.test.ts create mode 100644 packages/primitree/CHANGELOG.md create mode 100644 packages/primitree/LICENSE create mode 100644 packages/primitree/README.md create mode 100755 packages/primitree/bin/primitree.js create mode 100644 packages/primitree/package.json create mode 100644 scripts/prerelease-contract.test.mjs diff --git a/.changeset/README.md b/.changeset/README.md index a6dddf08..10edbbfd 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,8 +1,8 @@ # Changesets -Create a release note with `pnpm changeset`. The five public `@primitree/*` -packages use a fixed version group, so an approved changeset updates all five -to the same version. Apply pending release notes with -`pnpm version-packages`. +Create a release note with `pnpm changeset`. The five scoped packages and the +unscoped `primitree` launcher use a fixed version group, so an approved +changeset updates all six to the same version. Apply pending notes with `pnpm +version-packages`. Maintainers do not version or publish private workspaces through Changesets. diff --git a/.changeset/config.json b/.changeset/config.json index d07abe23..1458d6d6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,7 +3,14 @@ "changelog": ["@changesets/cli/changelog", null], "commit": false, "fixed": [ - ["@primitree/core", "@primitree/dtcg", "@primitree/cli", "@primitree/hooks", "@primitree/mcp"] + [ + "@primitree/core", + "@primitree/dtcg", + "@primitree/cli", + "@primitree/hooks", + "@primitree/mcp", + "primitree" + ] ], "linked": [], "access": "public", diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..6382683e --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,13 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "@primitree/core": "0.0.0", + "@primitree/dtcg": "0.0.0", + "@primitree/cli": "0.0.0", + "@primitree/hooks": "0.0.0", + "@primitree/mcp": "0.0.0", + "primitree": "0.0.0" + }, + "changesets": ["primitree-one"] +} diff --git a/.changeset/primitree-one.md b/.changeset/primitree-one.md new file mode 100644 index 00000000..cd16eaf4 --- /dev/null +++ b/.changeset/primitree-one.md @@ -0,0 +1,21 @@ +--- +'@primitree/core': major +'@primitree/dtcg': major +'@primitree/cli': major +'@primitree/hooks': major +'@primitree/mcp': major +'primitree': major +--- + +Launch the Primitree 1.0 package family. + +- `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. +- `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. +- `@primitree/cli` checks, inspects, compares, and builds configured token + projects. +- `@primitree/hooks` reads generated token output in React applications. +- `@primitree/mcp` exposes loaded token graphs to MCP clients. +- `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b566bd8..1c68c531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,13 +67,13 @@ jobs: - name: Run browser tests run: pnpm run test:e2e - - name: Validate stable release tag + - name: Validate release tag if: github.ref_type == 'tag' shell: bash run: | set -euo pipefail - if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Release tags must use vMAJOR.MINOR.PATCH" >&2 + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-next\.[0-9]+)?$ ]]; then + echo "Release tags must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-next.N" >&2 exit 1 fi pnpm run check:release-metadata diff --git a/README.md b/README.md index d3b00e5b..1e14d8a0 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Check a local DTCG source against project rules and build files for apps. ```sh -npx @primitree/cli check -npx @primitree/cli build +npx primitree@next check +npx primitree@next build ``` Configured builds can write this file set: @@ -36,7 +36,7 @@ config example. The older Figma form remains available: ```sh -npx @primitree/cli build variables.json +npx primitree@next build variables.json ``` Its token output follows DTCG 2025.10 plus a documented boolean extension. @@ -53,6 +53,7 @@ REST API. | Package | Purpose | | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| [`primitree`](packages/primitree) | Run the CLI through the short, unscoped package name | | [`@primitree/core`](packages/core) | Normalize exports, resolve aliases, compare revisions, and call the API | | [`@primitree/dtcg`](packages/dtcg) | Convert exports to token files and emit CSS, Tailwind, and TypeScript | | [`@primitree/cli`](packages/cli) | Check and inspect local DTCG sources; convert Figma exports; scaffold token projects | @@ -165,6 +166,7 @@ apps/ ├── figma-plugin/ Figma export plugin └── playground/ Standalone browser playground packages/ +├── primitree/ Unscoped CLI launcher ├── core/ Shared normalization, diff, API, and types ├── dtcg/ Token conversion and emitters ├── cli/ Command-line package diff --git a/apps/docs/content/docs/cli/check.mdx b/apps/docs/content/docs/cli/check.mdx index 842a927b..9825f625 100644 --- a/apps/docs/content/docs/cli/check.mdx +++ b/apps/docs/content/docs/cli/check.mdx @@ -18,7 +18,7 @@ forms and references, and owners. Paths start from the config file directory. Install the CLI in the project before importing its config helper: ```sh -npm install -D @primitree/cli +npm install -D @primitree/cli@next ``` ```ts diff --git a/apps/docs/content/docs/cli/index.mdx b/apps/docs/content/docs/cli/index.mdx index 759efb3c..f124cccb 100644 --- a/apps/docs/content/docs/cli/index.mdx +++ b/apps/docs/content/docs/cli/index.mdx @@ -6,7 +6,7 @@ description: Check local DTCG sources, build output files, compare changes, and The CLI requires Node.js 24 or newer. Add it as a development dependency: ```bash -pnpm add -D @primitree/cli +pnpm add -D @primitree/cli@next ``` Binary name: `primitree`. diff --git a/apps/docs/content/docs/getting-started/index.mdx b/apps/docs/content/docs/getting-started/index.mdx index 945c4053..19979561 100644 --- a/apps/docs/content/docs/getting-started/index.mdx +++ b/apps/docs/content/docs/getting-started/index.mdx @@ -9,7 +9,7 @@ You need Node.js 24 or newer. Contributors working in this monorepo need pnpm ## 1. Install the CLI ```bash -pnpm add -D @primitree/cli +pnpm add -D @primitree/cli@next ``` ## 2. Add a DTCG token file diff --git a/apps/docs/content/docs/hooks/index.mdx b/apps/docs/content/docs/hooks/index.mdx index a9772a98..b38cf962 100644 --- a/apps/docs/content/docs/hooks/index.mdx +++ b/apps/docs/content/docs/hooks/index.mdx @@ -4,7 +4,7 @@ description: Typed React hooks for built design tokens or live Figma Variables A --- ```bash -npm install @primitree/hooks react@^19.0.0 swr@^2.3.7 +npm install @primitree/hooks@next react@^19.0.0 swr@^2.3.7 ``` `@primitree/hooks` requires Node.js 24 or newer and React `^19.0.0`. diff --git a/apps/docs/content/docs/hooks/local-tokens.mdx b/apps/docs/content/docs/hooks/local-tokens.mdx index a606ed35..1b903cfe 100644 --- a/apps/docs/content/docs/hooks/local-tokens.mdx +++ b/apps/docs/content/docs/hooks/local-tokens.mdx @@ -6,7 +6,7 @@ description: Use built DTCG artifacts in React without the Figma API. Build the token files: ```bash -npx @primitree/cli build variables.json +npx primitree@next build variables.json ``` ## Setup diff --git a/apps/docs/content/docs/hooks/migration.mdx b/apps/docs/content/docs/hooks/migration.mdx index e8466b01..9f602d85 100644 --- a/apps/docs/content/docs/hooks/migration.mdx +++ b/apps/docs/content/docs/hooks/migration.mdx @@ -9,7 +9,7 @@ Primitree 1.0 moves the hooks package from `@figma-vars/hooks` to `@primitree/ho ```bash npm uninstall @figma-vars/hooks -npm install @primitree/hooks +npm install @primitree/hooks@next ``` | Before | Primitree 1.0 | diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index df2d0749..5a6d3bc7 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -18,7 +18,7 @@ files for an app to use. Install the CLI. Add a local DTCG file and `primitree.config.ts`. Run: ```bash -pnpm add -D @primitree/cli +pnpm add -D @primitree/cli@next pnpm exec primitree check --source brand pnpm exec primitree inspect semantic.motion.control --source brand cp tokens.json before.tokens.json diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx index 05dc7502..f339567e 100644 --- a/apps/docs/content/docs/mcp/index.mdx +++ b/apps/docs/content/docs/mcp/index.mdx @@ -57,7 +57,7 @@ The `--tokens` option selects the source for `get_token`, `resolve_context`, and ## Start the server ```bash -npx @primitree/mcp --tokens ./variables.json +npx @primitree/mcp@next --tokens ./variables.json ``` The command connects over stdio and logs its source path to standard error. diff --git a/docs/launch/announcement.md b/docs/launch/announcement.md index 5682a307..5d6f7020 100644 --- a/docs/launch/announcement.md +++ b/docs/launch/announcement.md @@ -1,6 +1,6 @@ # Primitree 1.0 launch copy -Draft copy for the 1.0.0 release. +Draft copy for the 1.0 prerelease. ## Show HN @@ -15,7 +15,7 @@ Hi HN. I built Primitree to turn an exported `variables.json` file into token files that live in a repository. ```sh -npx @primitree/cli build variables.json +npx primitree@next build variables.json git add design-tokens ``` @@ -56,7 +56,7 @@ Docs: https://primitree.com Build commit-ready token files from a Figma `variables.json` export. ```sh -npx @primitree/cli build variables.json +npx primitree@next build variables.json ``` Primitree writes DTCG 2025.10 tokens plus a documented boolean extension, @@ -119,13 +119,13 @@ Set Width 1200 Set Height 640 Set Theme "Catppuccin Mocha" -Type "npx @primitree/cli build variables.json" Enter +Type "npx primitree@next build variables.json" Enter Sleep 4s Type "find design-tokens -maxdepth 2 -type f | sort" Enter Sleep 4s Type "git add design-tokens && git status --short" Enter Sleep 4s -Type "npx @primitree/cli diff backup/variables.json variables.json" Enter +Type "npx primitree@next diff backup/variables.json variables.json" Enter Sleep 5s ``` @@ -135,7 +135,7 @@ switch one Resolver context, and download the zip. ## Launch checklist - [ ] Finish the [release runbook](../releasing.md). -- [ ] Publish the five `@primitree` packages in dependency order. +- [ ] Publish the five scoped packages and the unscoped `primitree` launcher in dependency order. - [ ] Verify the documentation site at https://primitree.com. - [ ] Verify the migration guide at https://primitree.com/docs/hooks/migration. diff --git a/docs/launch/v1.0.0-next.0.md b/docs/launch/v1.0.0-next.0.md new file mode 100644 index 00000000..8fb9bb26 --- /dev/null +++ b/docs/launch/v1.0.0-next.0.md @@ -0,0 +1,47 @@ +# Primitree 1.0.0-next.0 + +Status: Unreleased. + +Primitree checks a local DTCG source against project layer and owner rules. The +first public prerelease also reserves the unscoped `primitree` package name, so +the complete CLI is available through one short command: + +```sh +npx primitree@next check +``` + +The launcher forwards to `@primitree/cli`. It does not contain a second command +implementation or a separate configuration format. + +## Release highlights + +- `primitree check` evaluates a configured DTCG source and reports stable policy + findings. +- `primitree inspect` traces one token through its alias chain, owners, and + direct dependents. +- `primitree diff` reports token changes, affected aliases, and policy changes. +- `primitree build` writes DTCG, Resolver, CSS, Tailwind CSS, and TypeScript + files. Its check mode reports drift without writing. +- `@primitree/hooks` reads generated token output in React applications. +- `@primitree/mcp` exposes loaded token graphs to MCP clients. +- `primitree`, `@primitree/core`, `@primitree/dtcg`, `@primitree/cli`, + `@primitree/hooks`, and `@primitree/mcp` share version `1.0.0-next.0`. + +## Prerelease channel + +Install this version through npm's `next` tag. The release workflow verifies +that `latest` does not point to the prerelease. Stable `1.0.0` will move the +package family to `latest` after prerelease testing and a separate review. + +## Requirements + +- Node.js 24 or newer +- React `^19.0.0` and SWR `^2.3.7` for `@primitree/hooks` + +## Migration + +The React package moved from `@figma-vars/hooks` to `@primitree/hooks`. The +legacy npm package ends at version 4.0.0. + +Read the [migration guide](https://primitree.com/docs/hooks/migration) and the +[README](https://github.com/marklearst/primitree/blob/v1.0.0-next.0/README.md). diff --git a/docs/releasing.md b/docs/releasing.md index 2e4ed41a..80388e87 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,7 +1,8 @@ # Releasing Primitree Use this runbook to prepare, publish, or recover a Primitree release. CI writes -one checksummed artifact for the five public `@primitree/*` packages. +one checksummed artifact for five `@primitree/*` packages and the unscoped +`primitree` command launcher. Publish those files without rebuilding or substituting a tarball after the release checks pass. @@ -25,7 +26,7 @@ pnpm run test:e2e pnpm run check:release ``` -`check:release` builds once, packs the five public packages, validates their +`check:release` builds once, packs the six public packages, validates their metadata and artifact contents, runs Publint and Are the Types Wrong, checks the hooks size limit, and installs the tarballs in a temporary consumer. For each tarball, it runs a sanitized offline npm publish check. The repository @@ -51,22 +52,22 @@ GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata macOS ships `shasum`; GitHub's Linux jobs use `sha256sum --check SHA256SUMS` instead. -## Stable release semantics +## Release channel semantics -The workflow's sole release mode is stable. The five public packages must share -the same strict `MAJOR.MINOR.PATCH` version, and the accepted release tag must -equal `vMAJOR.MINOR.PATCH` using that shared version. The packages publish in -dependency order: core → dtcg → cli → hooks → mcp. Every stable publication -uses the npm dist-tag `latest`. +All six public packages share one version. The workflow accepts stable +`MAJOR.MINOR.PATCH` versions and `MAJOR.MINOR.PATCH-next.N` prereleases. The tag +must be the exact version prefixed with `v`. -This path does not support prerelease versions. A future prerelease needs a -separate design that changes the version and tag validators, artifact -filenames, tests, and workflow together, with an explicit non-`latest` dist-tag -such as `next`. +Packages publish in dependency order: core → dtcg → cli → hooks → mcp → +primitree. Stable versions use npm's `latest` dist-tag. Versions ending in +`-next.N` use `next` and GitHub marks their release as a prerelease. A first npm +publication can assign both `next` and `latest`; the release controller removes +that accidental `latest` tag from every prerelease package and verifies that +`next` identifies the exact version before continuing. ## Release artifact boundary -For `VERSION=1.0.0`, one release attempt contains these seven regular, +For `VERSION=1.0.0-next.0`, one release attempt contains these eight regular, non-symlink files and no others under `artifacts/npm/`: 1. `primitree-core-$VERSION.tgz` @@ -74,12 +75,13 @@ non-symlink files and no others under `artifacts/npm/`: 3. `primitree-cli-$VERSION.tgz` 4. `primitree-hooks-$VERSION.tgz` 5. `primitree-mcp-$VERSION.tgz` -6. `manifest.json` -7. `SHA256SUMS` +6. `primitree-$VERSION.tgz` +7. `manifest.json` +8. `SHA256SUMS` `manifest.json` fixes the shared version, package names, filenames, dependency order, and digests. The publish boundary gives the manifest a separate -validation because `SHA256SUMS` covers the five tarballs but not the manifest. +validation because `SHA256SUMS` covers the six tarballs but not the manifest. The required checks cover the manifest schema, tag/version equality, `SHA256SUMS` entries, and every tarball hash. Publication requires all of them to pass. @@ -100,11 +102,11 @@ npm token, and does not request an OIDC identity token. It applies `changeset version`, updates the lockfile without lifecycle scripts, and proves the result with a frozen, script-free install. -The initial 1.0.0 release needs no fabricated version pull request. The five -public package manifests already carry 1.0.0 and there is no pending changeset. -Do not fabricate a changeset or version pull request for that bootstrap. -Version pull request automation starts with later releases after reviewed -changesets reach `main`. +The initial `1.0.0-next.0` release uses the reviewed launch changeset and +`.changeset/pre.json`. Merge the resulting Changesets version pull request after +its exact head passes the protected checks. Keep prerelease mode active +for later `next` releases. Exit prerelease mode in a separate reviewed version +pull request before publishing stable `1.0.0`. Before enabling the workflow, verify the repository setting **Allow GitHub Actions to create and approve pull requests**. Run this check with a repository @@ -156,6 +158,21 @@ GITHUB_RELEASES=$( test "$GITHUB_RELEASES" = '[]' ``` +Make the source repository public before creating any npm package. This lets +package pages, provenance, issue links, and source links resolve to the same +public project. Review the visibility change in GitHub. Run and verify the +single repository mutation: + +```bash +gh repo edit marklearst/primitree \ + --visibility public \ + --accept-visibility-change-consequences +test "$(gh repo view marklearst/primitree --json visibility --jq '.visibility')" = PUBLIC +``` + +Stop if the repository is not public. Do not create a package or bootstrap +token until this check passes. + Confirm that an administrator enabled immutable releases. Run this check with a maintainer credential that has repository `Administration` read permission. The job-scoped `GITHUB_TOKEN` cannot perform this administrative check, so it stays @@ -892,7 +909,7 @@ in rollback mode. - [ ] Create a granular npm token that expires after one day and make the protected GitHub `npm` environment its sole storage location. Create the granular token in the npm website with the minimum publish access -needed for the five packages and CI 2FA bypass. Use it for the initial +needed for the six packages and CI 2FA bypass. Use it for the initial token-authenticated publish and for no other purpose. Do not paste it into a command, shell variable, file, issue, or log. @@ -913,25 +930,25 @@ GH_ENV_SECRETS=$( test "$(jq '[.[] | select(.name == "NPM_TOKEN")] | length' <<<"$GH_ENV_SECRETS")" = 1 ``` -### 5. Tag, publish, and create the GitHub Release +### 5. Tag, publish, and create the GitHub prerelease -- [ ] Create `v1.0.0` at the final verified commit and no other commit, push the single intended tag, and approve publication. +- [ ] Create `v1.0.0-next.0` at the final verified commit and no other commit, push the single intended tag, and approve publication. -For the initial release, create one annotated stable tag at the recorded +For the initial release, create one annotated prerelease tag at the recorded commit. Never use a blanket tag push. -Update the release copy before creating the tag. In the five package -changelogs, replace `1.0.0 (Unreleased)` with `1.0.0 (YYYY-MM-DD)`. In -`docs/launch/v1.0.0.md`, replace the draft status with -`Status: Released YYYY-MM-DD.` Use the same UTC date in all six files. The -tag-mode metadata check rejects a missing date, a mismatched date, or any -remaining `Unreleased` marker. +Update the release copy before creating the tag. In all six package changelogs, +change `1.0.0-next.0` to `1.0.0-next.0 (YYYY-MM-DD)`. In +`docs/launch/v1.0.0-next.0.md`, replace the draft status with +`Status: Released YYYY-MM-DD.` Use the same UTC date in all seven files. The +tag-mode metadata check rejects a missing date, a mismatched date, or an +`Unreleased` marker. Run `git tag -d` to clear a local tag from an earlier failed launch attempt before creating the release tag. ```bash -VERSION=1.0.0 +VERSION=1.0.0-next.0 GITHUB_REF_TYPE=tag GITHUB_REF_NAME="v$VERSION" pnpm run check:release-metadata git tag -d "v$VERSION" 2>/dev/null || true git tag -a "v$VERSION" "$FINAL_COMMIT" -m "Primitree $VERSION" @@ -943,12 +960,13 @@ Approve the protected `npm` environment job. The workflow performs the bootstrap token-authenticated publish, verifies npm provenance against the expected repository, workflow, tag, and commit, smoke-tests the public packages, and creates or resumes the immutable GitHub Release from the same -seven files. Record the run ID, package pages, `latest` dist-tags, provenance, -release notes, asset digests, and final release URL. +eight files. Record the run ID, package pages, `next` dist-tags, absence of the +prerelease from `latest`, provenance, release notes, asset digests, and final +prerelease URL. -### 6. Configure all five trusted publishers +### 6. Configure all six trusted publishers -- [ ] Configure trusted publishing for all five packages and verify each saved relationship. +- [ ] Configure trusted publishing for all six packages and verify each saved relationship. Use a browser-authenticated local npm >=11.15 session with package write access. The bootstrap granular access token cannot authorize `npm trust`; authenticate @@ -963,11 +981,13 @@ npm trust github '@primitree/dtcg' --repository marklearst/primitree --file ci.y npm trust github '@primitree/cli' --repository marklearst/primitree --file ci.yml --environment npm --allow-publish --yes --registry=https://registry.npmjs.org/ npm trust github '@primitree/hooks' --repository marklearst/primitree --file ci.yml --environment npm --allow-publish --yes --registry=https://registry.npmjs.org/ npm trust github '@primitree/mcp' --repository marklearst/primitree --file ci.yml --environment npm --allow-publish --yes --registry=https://registry.npmjs.org/ +npm trust github 'primitree' --repository marklearst/primitree --file ci.yml --environment npm --allow-publish --yes --registry=https://registry.npmjs.org/ npm trust list '@primitree/core' --registry=https://registry.npmjs.org/ npm trust list '@primitree/dtcg' --registry=https://registry.npmjs.org/ npm trust list '@primitree/cli' --registry=https://registry.npmjs.org/ npm trust list '@primitree/hooks' --registry=https://registry.npmjs.org/ npm trust list '@primitree/mcp' --registry=https://registry.npmjs.org/ +npm trust list 'primitree' --registry=https://registry.npmjs.org/ ``` `npm trust list` confirms the saved configuration but cannot prove GitHub OIDC @@ -976,7 +996,7 @@ that GitHub OIDC can publish the package. ### 7. Delete the GitHub environment secret -- [ ] Delete `NPM_TOKEN` from the protected GitHub environment after you verify all five trust entries. +- [ ] Delete `NPM_TOKEN` from the protected GitHub environment after you verify all six trust entries. ```bash set -euo pipefail @@ -1014,7 +1034,7 @@ Never substitute a token value for the ID. ### 9. Require package MFA and disallow token publishing -- [ ] Require 2FA while disallowing token-based publishing on all five packages. +- [ ] Require 2FA while disallowing token-based publishing on all six packages. Before this step, you must save trusted publishing, delete the GitHub secret, and revoke the bootstrap token. Open each package on npm, go to @@ -1026,8 +1046,9 @@ and revoke the bootstrap token. Open each package on npm, go to - `@primitree/cli` - `@primitree/hooks` - `@primitree/mcp` +- `primitree` -The npm CLI does not expose the disallow-tokens setting. After saving all five +The npm CLI does not expose the disallow-tokens setting. After saving all six packages, refresh each Publishing access page and confirm **Require two-factor authentication and disallow tokens** remains selected. Trusted OIDC publishing remains available through the exact repository, @@ -1038,16 +1059,17 @@ workflow, and environment relationship. - [ ] Verify every replacement package, the production documentation site, and the migration page. ```bash -npm view "@primitree/core@1.0.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/dtcg@1.0.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/cli@1.0.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/hooks@1.0.0" version --registry=https://registry.npmjs.org/ -npm view "@primitree/mcp@1.0.0" version --registry=https://registry.npmjs.org/ +npm view "@primitree/core@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +npm view "@primitree/dtcg@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +npm view "@primitree/cli@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +npm view "@primitree/hooks@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +npm view "@primitree/mcp@1.0.0-next.0" version --registry=https://registry.npmjs.org/ +npm view "primitree@1.0.0-next.0" version --registry=https://registry.npmjs.org/ curl --fail --silent --show-error https://primitree.com/ >/dev/null curl --fail --silent --show-error https://primitree.com/docs/hooks/migration >/dev/null ``` -All five replacement packages, the production documentation site, and the +All six replacement packages, the production documentation site, and the migration page must be live and correct before deprecation. ### 11. Deprecate the sole legacy target: 4.0.0 @@ -1068,7 +1090,7 @@ Use GitHub **Re-run failed jobs** on the same tag workflow run as the sole supported selective recovery path. Do not start a new workflow run. The `npm publish` commands in this section are reference material; do not execute them from a local machine. The failed job downloads the unchanged same-run -artifact, validates its seven-file boundary again, and keeps the job's OIDC +artifact, validates its eight-file boundary again, and keeps the job's OIDC provenance context. Never rebuild or replace those files during recovery. The publish step queries every exact package version before its corresponding @@ -1084,7 +1106,8 @@ Audit the workflow's registry decisions with these commands. Run recovery through GitHub Actions: ```bash -VERSION=1.0.0 +VERSION=1.0.0-next.0 +RELEASE_CHANNEL=next ARTIFACT_DIR=artifacts/npm (cd "$ARTIFACT_DIR" && shasum -a 256 -c SHA256SUMS) npm view "@primitree/core@$VERSION" version --registry=https://registry.npmjs.org @@ -1092,6 +1115,7 @@ npm view "@primitree/dtcg@$VERSION" version --registry=https://registry.npmjs.or npm view "@primitree/cli@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/hooks@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/mcp@$VERSION" version --registry=https://registry.npmjs.org +npm view "primitree@$VERSION" version --registry=https://registry.npmjs.org ``` GitHub's Linux jobs use `sha256sum --check SHA256SUMS` for the checksum step. @@ -1099,27 +1123,29 @@ Treat npm `E404` as a missing package. Any other error, including an authentication, permission, rate-limit, DNS, or registry failure, stops recovery. The workflow uses its registry query to decide whether to execute each of these -five literal commands in dependency order. Maintainers must not execute them +six literal commands in dependency order. Maintainers must not execute them from a local machine because local publication cannot preserve the workflow's provenance: ```bash -npm publish "$ARTIFACT_DIR/primitree-core-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts -npm publish "$ARTIFACT_DIR/primitree-dtcg-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts -npm publish "$ARTIFACT_DIR/primitree-cli-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts -npm publish "$ARTIFACT_DIR/primitree-hooks-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts -npm publish "$ARTIFACT_DIR/primitree-mcp-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-core-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-dtcg-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-cli-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-hooks-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-mcp-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts +npm publish "$ARTIFACT_DIR/primitree-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts ``` ### Wrong dist-tag -Inspect and repair a dist-tag without republishing. Use this core example for -the package whose dist-tag needs repair: +Inspect and repair a dist-tag without republishing. A prerelease must remain on +`next` and must not occupy `latest`. Use this core example for the package whose +dist-tag needs repair: ```bash npm dist-tag ls "@primitree/core" --registry=https://registry.npmjs.org -npm dist-tag add "@primitree/core@$VERSION" latest --registry=https://registry.npmjs.org -npm dist-tag rm "@primitree/core" next --registry=https://registry.npmjs.org +npm dist-tag add "@primitree/core@$VERSION" next --registry=https://registry.npmjs.org +npm dist-tag rm "@primitree/core" latest --registry=https://registry.npmjs.org ``` ### Bad package contents @@ -1190,19 +1216,20 @@ gh run view "$OLD_RUN_ID" --json status,conclusion,jobs ``` Before continuing, require the output to prove the publish job never started. -Re-query all five versions against the release registry and require all five +Re-query all six versions against the release registry and require all six commands to return npm `E404`: ```bash -VERSION=1.0.0 +VERSION=1.0.0-next.0 npm view "@primitree/core@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/dtcg@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/cli@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/hooks@$VERSION" version --registry=https://registry.npmjs.org npm view "@primitree/mcp@$VERSION" version --registry=https://registry.npmjs.org +npm view "primitree@$VERSION" version --registry=https://registry.npmjs.org ``` -After cancellation is terminal, the publish job never started, and all five +After cancellation is terminal, the publish job never started, and all six versions are absent, delete and recreate the tag at the verified commit and push that single ref: diff --git a/package.json b/package.json index 3e49ec70..64c514de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "primitree", + "name": "primitree-workspace", "private": true, - "description": "Monorepo for the @primitree packages and documentation.", + "description": "Monorepo for the Primitree packages and documentation.", "author": "Mark Learst", "license": "MIT", "type": "module", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 6a3282db..ed4d81ca 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog -## 1.0.0 (Unreleased) +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. + +### Patch Changes + +- Updated dependencies + - @primitree/core@1.0.0-next.0 + - @primitree/dtcg@1.0.0-next.0 Initial release of `@primitree/cli`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 74e6ab30..49954201 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,7 +4,7 @@ keeps the older commands for Figma variables exports during the 1.0 update. ```sh -npx @primitree/cli build variables.json +npx primitree@next build variables.json ``` ## Requirements @@ -129,7 +129,7 @@ and owner rules. Use `--source` when the config contains several sources. Install the CLI in the project before importing its config helper: ```sh -npm install -D @primitree/cli +npm install -D @primitree/cli@next ``` ```ts @@ -244,7 +244,7 @@ the REST API. - [`@primitree/mcp`](https://www.npmjs.com/package/@primitree/mcp) serves token data through MCP. Read the [Primitree documentation](https://primitree.com) or review the -[1.0.0 changelog](CHANGELOG.md). +[1.0.0-next.0 changelog](CHANGELOG.md). ## License diff --git a/packages/cli/package.json b/packages/cli/package.json index b7c2ac3c..2084e4a1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/cli", - "version": "1.0.0", + "version": "1.0.0-next.0", "description": "Build, compare, check, scaffold, and export Figma Variables token projects.", "author": "Mark Learst", "license": "MIT", @@ -9,6 +9,7 @@ "primitree": "./dist/index.js" }, "exports": { + "./bin": "./dist/index.js", "./config": { "types": "./dist/config.d.ts", "import": "./dist/config.js" diff --git a/packages/cli/tests/launcher-package.test.ts b/packages/cli/tests/launcher-package.test.ts new file mode 100644 index 00000000..f3af589c --- /dev/null +++ b/packages/cli/tests/launcher-package.test.ts @@ -0,0 +1,40 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const launcherDirectory = path.join(import.meta.dirname, '../../primitree') +const manifestPath = path.join(launcherDirectory, 'package.json') +const launcherPath = path.join(launcherDirectory, 'bin/primitree.js') + +describe('unscoped Primitree launcher', () => { + it('forwards the public command to the scoped CLI', async () => { + expect(existsSync(manifestPath)).toBe(true) + expect(existsSync(launcherPath)).toBe(true) + if (!existsSync(manifestPath) || !existsSync(launcherPath)) { + return + } + + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) + expect(manifest.name).toBe('primitree') + expect(manifest.bin).toEqual({ primitree: './bin/primitree.js' }) + expect(manifest.exports).toBeUndefined() + expect(manifest.dependencies).toEqual({ '@primitree/cli': 'workspace:*' }) + + const help = spawnSync(process.execPath, [launcherPath, '--help'], { + cwd: launcherDirectory, + encoding: 'utf8', + }) + expect(help.status).toBe(0) + expect(help.stdout).toContain('primitree [options]') + expect(help.stderr).toBe('') + + const unknown = spawnSync(process.execPath, [launcherPath, 'unknown'], { + cwd: launcherDirectory, + encoding: 'utf8', + }) + expect(unknown.status).toBe(2) + expect(unknown.stderr).toContain('Unknown command: unknown') + }) +}) diff --git a/packages/cli/tests/public-copy.test.ts b/packages/cli/tests/public-copy.test.ts index 7266166c..d6d1ce0b 100644 --- a/packages/cli/tests/public-copy.test.ts +++ b/packages/cli/tests/public-copy.test.ts @@ -98,6 +98,7 @@ describe('public CLI copy', () => { it('exposes the Primitree CLI without a hooks export command', () => { expect(cliManifest.bin).toEqual({ primitree: './dist/index.js' }) + expect(cliManifest.exports['./bin']).toBe('./dist/index.js') expect(hooksManifest.bin).toBeUndefined() expect(hooksManifest.files).not.toContain('scripts/export-variables.mjs') }) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bc5d0eac..fa376d13 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,6 +1,21 @@ # Changelog -## 1.0.0 (Unreleased) +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. Initial release of `@primitree/core`. diff --git a/packages/core/README.md b/packages/core/README.md index 16cb1a3d..ab446c16 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ resolution, comparison, and REST API functions shared across Primitree. ```sh -npm install @primitree/core +npm install @primitree/core@next ``` ## Requirements @@ -89,7 +89,7 @@ characters, so do not log it. - [`@primitree/hooks`](https://www.npmjs.com/package/@primitree/hooks) provides React hooks. Read the [Primitree documentation](https://primitree.com) or review the -[1.0.0 changelog](CHANGELOG.md). +[1.0.0-next.0 changelog](CHANGELOG.md). ## License diff --git a/packages/core/package.json b/packages/core/package.json index 8de651dc..44eadc4f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/core", - "version": "1.0.0", + "version": "1.0.0-next.0", "description": "Types and functions for normalizing, resolving, comparing, and updating Figma Variables.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/dtcg/CHANGELOG.md b/packages/dtcg/CHANGELOG.md index 6902937f..bfe849fe 100644 --- a/packages/dtcg/CHANGELOG.md +++ b/packages/dtcg/CHANGELOG.md @@ -1,6 +1,26 @@ # Changelog -## 1.0.0 (Unreleased) +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. + +### Patch Changes + +- Updated dependencies + - @primitree/core@1.0.0-next.0 Initial release of `@primitree/dtcg`. diff --git a/packages/dtcg/README.md b/packages/dtcg/README.md index 60c3a574..917f6d09 100644 --- a/packages/dtcg/README.md +++ b/packages/dtcg/README.md @@ -6,7 +6,7 @@ documents, a Resolver document, CSS custom properties, a Tailwind CSS v4 theme, and TypeScript token accessors. ```sh -npm install @primitree/dtcg +npm install @primitree/dtcg@next ``` ## Requirements @@ -244,7 +244,7 @@ Use [`@primitree/cli`](https://www.npmjs.com/package/@primitree/cli) when you want Primitree to write these files to disk. Read the [Primitree documentation](https://primitree.com) or review the -[1.0.0 changelog](CHANGELOG.md). +[1.0.0-next.0 changelog](CHANGELOG.md). ## License diff --git a/packages/dtcg/package.json b/packages/dtcg/package.json index 1cb5cde5..b32075c7 100644 --- a/packages/dtcg/package.json +++ b/packages/dtcg/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/dtcg", - "version": "1.0.0", + "version": "1.0.0-next.0", "description": "Read Primitree's supported DTCG 2025.10 value subset into Core and convert Figma variables to DTCG, Resolver contexts, CSS, Tailwind CSS, and TypeScript.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index ea048ed0..14c48470 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -1,11 +1,32 @@ # Changelog +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. + +### Patch Changes + +- Updated dependencies + - @primitree/core@1.0.0-next.0 + - @primitree/dtcg@1.0.0-next.0 + npm records 24 published versions of the legacy `@figma-vars/hooks` package. Each published section uses its UTC timestamp from the public registry. Published entries appear in reverse registry order. -## 1.0.0 (Unreleased) - Version 1 moves the React package to `@primitree/hooks` and releases it with the other Primitree packages. diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 10a721b7..7ca33037 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,7 +10,7 @@ React hooks for built design tokens and the Figma Variables REST API. ```sh -npm install @primitree/hooks react swr +npm install @primitree/hooks@next react swr ``` ## Requirements @@ -21,7 +21,7 @@ npm install @primitree/hooks react swr ## Read built tokens -Run `npx @primitree/cli build variables.json`. Pass the generated token +Run `npx primitree@next build variables.json`. Pass the generated token documents and Resolver to `TokensProvider`. ```tsx @@ -202,7 +202,7 @@ Replace `@figma-vars/hooks` with `@primitree/hooks` in dependencies and imports. The legacy npm package ends at version 4.0.0. Read the [migration guide](https://primitree.com/docs/hooks/migration) and the -[1.0.0 changelog](CHANGELOG.md). +[1.0.0-next.0 changelog](CHANGELOG.md). ## License diff --git a/packages/hooks/package.json b/packages/hooks/package.json index c7c0b160..d0febf24 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/hooks", - "version": "1.0.0", + "version": "1.0.0-next.0", "description": "React 19 hooks for built Primitree tokens and the Figma Variables REST API.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 395d882a..d30be83a 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog -## 1.0.0 (Unreleased) +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. + +### Patch Changes + +- Updated dependencies + - @primitree/core@1.0.0-next.0 + - @primitree/dtcg@1.0.0-next.0 Initial release of `@primitree/mcp`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d4ad7d22..4e4d5338 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -67,7 +67,7 @@ Tool lookups retain valid literals and aliases without an effective `$type`. Their results omit `type`, and `$type` filters exclude them. Read the [Primitree documentation](https://primitree.com) or review the -[1.0.0 changelog](CHANGELOG.md). +[1.0.0-next.0 changelog](CHANGELOG.md). ## License diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 801a897b..f43921bc 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/mcp", - "version": "1.0.0", + "version": "1.0.0-next.0", "description": "MCP server for listing, resolving, searching, and comparing Primitree design tokens.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/primitree/CHANGELOG.md b/packages/primitree/CHANGELOG.md new file mode 100644 index 00000000..c44730b6 --- /dev/null +++ b/packages/primitree/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 1.0.0-next.0 + +### Major Changes + +- Launch the Primitree 1.0 package family. + + - `@primitree/core` normalizes Figma variables, resolves aliases, compares + revisions, and evaluates token policy. + - `@primitree/dtcg` reads DTCG 2025.10 tokens and builds DTCG, Resolver, CSS, + Tailwind CSS, and TypeScript output. + - `@primitree/cli` checks, inspects, compares, and builds configured token + projects. + - `@primitree/hooks` reads generated token output in React applications. + - `@primitree/mcp` exposes loaded token graphs to MCP clients. + - `primitree` provides the unscoped `primitree` command through + `@primitree/cli`. + +### Patch Changes + +- Updated dependencies + - @primitree/cli@1.0.0-next.0 + +Initial prerelease of the unscoped `primitree` command launcher. diff --git a/packages/primitree/LICENSE b/packages/primitree/LICENSE new file mode 100644 index 00000000..d99f56c6 --- /dev/null +++ b/packages/primitree/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Mark Learst + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/primitree/README.md b/packages/primitree/README.md new file mode 100644 index 00000000..3b8bb9de --- /dev/null +++ b/packages/primitree/README.md @@ -0,0 +1,21 @@ +# primitree + +`primitree` provides the unscoped command for +[`@primitree/cli`](https://www.npmjs.com/package/@primitree/cli). + +```sh +npx primitree@next --help +npx primitree@next check +``` + +The `next` tag carries the 1.0 prerelease. Npm's `latest` tag stays unchanged. +The stable release will support `npx primitree` without a tag. + +Install `@primitree/cli@next` when a project imports +`@primitree/cli/config`. The launcher contains no second CLI implementation; it +forwards the command to that package. Programmatic token APIs remain in +`@primitree/core` and `@primitree/dtcg`. + +Primitree requires Node.js 24 or newer. + +See the [changelog](CHANGELOG.md) for release notes. diff --git a/packages/primitree/bin/primitree.js b/packages/primitree/bin/primitree.js new file mode 100755 index 00000000..6d060b36 --- /dev/null +++ b/packages/primitree/bin/primitree.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '@primitree/cli/bin' diff --git a/packages/primitree/package.json b/packages/primitree/package.json new file mode 100644 index 00000000..d9c64577 --- /dev/null +++ b/packages/primitree/package.json @@ -0,0 +1,44 @@ +{ + "name": "primitree", + "version": "1.0.0-next.0", + "description": "Run the Primitree design token CLI from the unscoped package name.", + "author": "Mark Learst", + "license": "MIT", + "type": "module", + "bin": { + "primitree": "./bin/primitree.js" + }, + "files": [ + "bin", + "CHANGELOG.md" + ], + "keywords": [ + "design tokens", + "dtcg", + "figma variables", + "cli" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/marklearst/primitree.git", + "directory": "packages/primitree" + }, + "bugs": { + "url": "https://github.com/marklearst/primitree/issues" + }, + "homepage": "https://github.com/marklearst/primitree#readme", + "engines": { + "node": ">=24.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/marklearst" + }, + "dependencies": { + "@primitree/cli": "workspace:*" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8237ce54..e5215f76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,7 +209,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) packages/core: devDependencies: @@ -227,7 +227,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/dtcg: dependencies: @@ -373,6 +373,12 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + packages/primitree: + dependencies: + '@primitree/cli': + specifier: workspace:* + version: link:../cli + packages: '@adobe/css-tools@4.5.0': @@ -6323,7 +6329,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -6393,7 +6399,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.3(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: diff --git a/scripts/brand-rules.mjs b/scripts/brand-rules.mjs index 5dc2946c..ee7f91e5 100644 --- a/scripts/brand-rules.mjs +++ b/scripts/brand-rules.mjs @@ -5,6 +5,7 @@ import postcss from 'postcss' const APPROVED_REFERENCE_PATHS = new Set([ 'apps/docs/content/docs/hooks/migration.mdx', 'docs/releasing.md', + 'docs/launch/v1.0.0-next.0.md', 'docs/launch/v1.0.0.md', 'docs/plans/2026-07-28-primitree-clean-break-implementation.md', 'packages/hooks/CHANGELOG.md', diff --git a/scripts/check-brand.test.mjs b/scripts/check-brand.test.mjs index d5f54dfd..8756d42d 100644 --- a/scripts/check-brand.test.mjs +++ b/scripts/check-brand.test.mjs @@ -209,6 +209,10 @@ test('permits only migration references in approved files', () => { path: 'docs/launch/v1.0.0.md', content: `The React package moved from ${hooksScope} to @primitree/hooks.`, }, + { + path: 'docs/launch/v1.0.0-next.0.md', + content: `The React package moved from ${hooksScope} to @primitree/hooks.`, + }, { path: 'docs/plans/2026-07-28-primitree-clean-break-implementation.md', content: `Keep ${hooksScope}@4.0.0 in migration instructions.`, diff --git a/scripts/check-prose.test.mjs b/scripts/check-prose.test.mjs index cf0b4d44..88c58774 100644 --- a/scripts/check-prose.test.mjs +++ b/scripts/check-prose.test.mjs @@ -841,9 +841,9 @@ test('built prose validation requires every API page and exact public declaratio ['@primitree/mcp', ['dist/index.d.ts', 'dist/cli.d.ts']], ]) const declarationFiles = PUBLIC_RELEASE_PACKAGES.flatMap(config => - requiredDeclarations - .get(config.name) - .map(file => path.join(root, config.path, file)) + (requiredDeclarations.get(config.name) ?? []).map(file => + path.join(root, config.path, file) + ) ) assert.doesNotThrow(() => diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 26fbbcc9..cba7267d 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -12,7 +12,8 @@ import { RELEASE_REPOSITORY_TYPE, } from './release-config.mjs' -const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ +const RELEASE_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-next\.(0|[1-9]\d*))?$/ const EXPECTED_AUTHOR = 'Mark Learst' const EXPECTED_LICENSE = 'MIT' const FORMER_PACKAGE_SCOPES = ['@figma-vars/', '@figmavars/'] @@ -589,8 +590,8 @@ export function validateWorkspaceRootManifest(manifest) { `Workspace root manifest check failed:\n- ${errors.join('\n- ')}` ) } - if (manifest.name !== 'primitree') { - errors.push('Workspace root must be named primitree') + if (manifest.name !== 'primitree-workspace') { + errors.push('Workspace root must be named primitree-workspace') } if (manifest.private !== true) { errors.push('Workspace root must be private') diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index dce5b329..7c939e1c 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -65,6 +65,10 @@ const v1ReleaseNotesUrl = new URL('../docs/launch/v1.0.0.md', import.meta.url) const v1ReleaseNotes = existsSync(v1ReleaseNotesUrl) ? readFileSync(v1ReleaseNotesUrl, 'utf8') : '' +const nextReleaseNotes = readFileSync( + new URL('../docs/launch/v1.0.0-next.0.md', import.meta.url), + 'utf8' +) const releaseRunbookUrl = new URL('../docs/releasing.md', import.meta.url) const releaseRunbook = existsSync(releaseRunbookUrl) ? readFileSync(releaseRunbookUrl, 'utf8') @@ -137,6 +141,7 @@ const RELEASE_PUBLISH_PACKAGES = [ { name: '@primitree/cli', stem: 'cli' }, { name: '@primitree/hooks', stem: 'hooks' }, { name: '@primitree/mcp', stem: 'mcp' }, + { name: 'primitree', stem: 'primitree' }, ] const TRUST_COMMANDS = RELEASE_PUBLISH_PACKAGES.map( ({ name }) => @@ -581,6 +586,7 @@ test('exports one immutable dependency-ordered release inventory', () => { '@primitree/cli', '@primitree/hooks', '@primitree/mcp', + 'primitree', ] ) assert.deepEqual( @@ -649,6 +655,7 @@ test('exports one immutable dependency-ordered release inventory', () => { requiredBin: 'primitree', requiredBinTarget: './dist/index.js', exportSignatures: [ + './bin=./dist/index.js', './config:types=./dist/config.d.ts', './config:import=./dist/config.js', ], @@ -680,6 +687,15 @@ test('exports one immutable dependency-ordered release inventory', () => { '.:import:default=./dist/index.js', ], }, + { + name: 'primitree', + attwProfile: null, + requiredFiles: ['bin', 'CHANGELOG.md'], + requiredDeclarationFiles: [], + requiredBin: 'primitree', + requiredBinTarget: './bin/primitree.js', + exportSignatures: [], + }, ] ) assert.deepEqual( @@ -706,6 +722,11 @@ test('exports one immutable dependency-ordered release inventory', () => { 'packages/mcp/package.json', ['@primitree/core', '@primitree/dtcg'], ], + [ + 'packages/primitree', + 'packages/primitree/package.json', + ['@primitree/cli'], + ], ] ) assert.equal(Object.isFrozen(PUBLIC_RELEASE_PACKAGES), true) @@ -948,12 +969,15 @@ test('requires private workspaces and the workspace root to omit versions', () = ) assert.doesNotThrow(() => - validateWorkspaceRootManifest({ name: 'primitree', private: true }) + validateWorkspaceRootManifest({ + name: 'primitree-workspace', + private: true, + }) ) assert.throws( () => validateWorkspaceRootManifest({ - name: 'primitree', + name: 'primitree-workspace', private: true, version: '1.0.0', }), @@ -961,6 +985,17 @@ test('requires private workspaces and the workspace root to omit versions', () = ) }) +test('accepts a coordinated next prerelease version and matching tag', () => { + const publicPackages = makePublicPackages() + for (const pkg of publicPackages) pkg.manifest.version = '1.0.0-next.0' + + assert.doesNotThrow(() => validate({ publicPackages, tag: 'v1.0.0-next.0' })) + assert.throws( + () => validate({ publicPackages, tag: 'v1.0.0' }), + /does not match package version/u + ) +}) + test('rejects non-plain package records and manifest containers', () => { assert.throws( () => validate({ publicPackages: new Map() }), @@ -1085,7 +1120,7 @@ test('rejects circular manifest data without recursing forever', () => { ) }) -test('rejects missing, duplicate, and sixth public workspaces', () => { +test('rejects missing, duplicate, and unexpected public workspaces', () => { const missing = makePublicPackages().slice(1) assert.throws( () => validate({ publicPackages: missing }), @@ -1099,8 +1134,8 @@ test('rejects missing, duplicate, and sixth public workspaces', () => { /duplicate public workspace packages\/core\/package\.json/ ) - const sixth = makePublicPackages() - sixth.push({ + const unexpected = makePublicPackages() + unexpected.push({ path: 'packages/experimental', manifestPath: 'packages/experimental/package.json', manifest: { @@ -1111,7 +1146,7 @@ test('rejects missing, duplicate, and sixth public workspaces', () => { licenseText, }) assert.throws( - () => validate({ publicPackages: sixth }), + () => validate({ publicPackages: unexpected }), /unexpected public workspace packages\/experimental\/package\.json/ ) }) @@ -1295,7 +1330,7 @@ test('repository validation is independent of the process cwd', () => { assert.equal(result.status, 0, result.stderr) assert.match( result.stdout, - /Release metadata valid for 5 public packages at 1\.0\.0/ + /Release metadata valid for 6 public packages at 1\.0\.0-next\.0/ ) } finally { rmSync(cwd, { recursive: true, force: true }) @@ -1324,8 +1359,10 @@ test('requires Node 24 for the source workspace, public packages, and Node build Array(PUBLIC_RELEASE_PACKAGES.length).fill('>=24.0.0') ) assert.deepEqual( - publicManifests.map(manifest => manifest.devDependencies?.['@types/node']), - Array(PUBLIC_RELEASE_PACKAGES.length).fill('^24.13.3') + publicManifests + .filter(manifest => manifest.name !== 'primitree') + .map(manifest => manifest.devDependencies?.['@types/node']), + Array(PUBLIC_RELEASE_PACKAGES.length - 1).fill('^24.13.3') ) assert.match(cliTsupConfig, /target: 'node24'/) assert.match(mcpTsupConfig, /target: 'node24'/) @@ -1800,7 +1837,10 @@ test('builds and uploads one exact release artifact in quality', () => { 'quality job' ) assert.match(quality, /if: github\.ref_type == 'tag'/) - assert.match(quality, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/) + assert.match( + quality, + /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\(-next\\\.\[0-9\]\+\)\?\$/ + ) assert.match(quality, /path: packages\/\*\/coverage\//) for (const packageName of ['core', 'dtcg', 'cli', 'hooks', 'mcp']) { assert.match( @@ -1952,6 +1992,15 @@ test('keeps reviewed v1 release notes project-focused', () => { assert.doesNotMatch(v1ReleaseNotes, /—/) }) +test('documents the unscoped launcher and next channel in prerelease notes', () => { + assert.match(nextReleaseNotes, /^# Primitree 1\.0\.0-next\.0$/m) + assert.match(nextReleaseNotes, /npx primitree@next check/) + assert.match(nextReleaseNotes, /forwards to `@primitree\/cli`/) + assert.match(nextReleaseNotes, /`latest` does not point to the prerelease/) + assert.match(nextReleaseNotes, /blob\/v1\.0\.0-next\.0\/README\.md/) + assert.doesNotMatch(nextReleaseNotes, /—/) +}) + test('dates public release copy before creating a tag', () => { const external = extractMarkdownSection( releaseRunbook, @@ -1959,11 +2008,11 @@ test('dates public release copy before creating a tag', () => { ) const tagPhase = extractMarkdownSection( external, - '### 5. Tag, publish, and create the GitHub Release' + '### 5. Tag, publish, and create the GitHub prerelease' ) - assert.match(tagPhase, /five package\s+changelogs/i) - assert.match(tagPhase, /`1\.0\.0 \(Unreleased\)`/) + assert.match(tagPhase, /all six package changelogs/i) + assert.match(tagPhase, /`1\.0\.0-next\.0`/) assert.match(tagPhase, /`Status: Released YYYY-MM-DD\.`/) assert.match(tagPhase, /tag-mode metadata check rejects[\s\S]*Unreleased/i) assert.match( @@ -2001,13 +2050,16 @@ test('documents exact contributor and version pull request boundaries', () => { ) assert.match( versionPullRequests, - /initial 1\.0\.0[\s\S]*already carry 1\.0\.0[\s\S]*no pending changeset/i + /initial `1\.0\.0-next\.0`[\s\S]*reviewed launch changeset/i ) assert.match( versionPullRequests, - /do not fabricate[\s\S]*version pull request/i + /keep prerelease mode active[\s\S]*later `next` releases/i + ) + assert.match( + versionPullRequests, + /exit prerelease mode[\s\S]*stable `1\.0\.0`/i ) - assert.match(versionPullRequests, /later releases/i) assert.match( versionPullRequests, /GitHub\s+Actions[\s\S]*create and approve pull requests/i @@ -2049,8 +2101,8 @@ test('freezes the launch administration order and credential boundaries', () => '### 2. Merge and bind exact main', '### 3. Verify the automatic main deployment', '### 4. Create the bootstrap credential and protected environment', - '### 5. Tag, publish, and create the GitHub Release', - '### 6. Configure all five trusted publishers', + '### 5. Tag, publish, and create the GitHub prerelease', + '### 6. Configure all six trusted publishers', '### 7. Delete the GitHub environment secret', '### 8. Revoke the exact bootstrap token', '### 9. Require package MFA and disallow token publishing', @@ -3169,7 +3221,7 @@ test('requires exact automatic main verification before legacy deprecation', () 'PRODUCTION_DEPLOYMENT_ID="$OBSERVED_DEPLOYMENT_ID"', `test "$(jq -r '.meta.githubCommitSha' <<<"$PRODUCTION_JSON")" =`, 'wait_for_verified_public_site', - 'npm view "@primitree/core@1.0.0"', + 'npm view "@primitree/core@1.0.0-next.0"', LEGACY_DEPRECATION_COMMAND, ], 'automatic production and deprecation' @@ -3244,7 +3296,7 @@ test('requires exact automatic main verification before legacy deprecation', () ) assert.match( external, - /five replacement packages[\s\S]*production documentation site[\s\S]*migration page[\s\S]*before deprecation/i + /six replacement packages[\s\S]*production documentation site[\s\S]*migration page[\s\S]*before deprecation/i ) assert.doesNotMatch(external, /npm deprecate[^\n]*@(?:\*|[^"\s]+@\*)/) }) @@ -3307,24 +3359,17 @@ test('freezes the exact local preflight and supported toolchain boundaries', () assert.match(preflight, /npm 11\.18\.0[\s\S]*publish/i) }) -test('documents stable-only semantics and the exact seven-file artifact boundary', () => { +test('documents stable and next channels with the exact eight-file artifact boundary', () => { const semantics = extractMarkdownSection( releaseRunbook, - '## Stable release semantics' - ) - assert.match(semantics, /sole release mode is stable/i) - assert.match( - semantics, - /five public packages\s+must share\s+the same strict `MAJOR\.MINOR\.PATCH` version/i + '## Release channel semantics' ) + assert.match(semantics, /all six public packages share one version/i) assert.doesNotMatch(semantics, /public and private workspaces/i) - assert.match( - semantics, - /accepted release\s+tag must\s+equal `vMAJOR\.MINOR\.PATCH`/ - ) - assert.match(semantics, /dist-tag `latest`/) - assert.match(semantics, /does not support prerelease versions/i) - assert.match(semantics, /future[\s\S]*separate design/i) + assert.match(semantics, /tag\s+must be the exact version prefixed with `v`/i) + assert.match(semantics, /`latest` dist-tag/) + assert.match(semantics, /`-next\.N` use `next`/i) + assert.match(semantics, /removes[\s\S]*accidental `latest`/i) const artifactBoundary = extractMarkdownSection( releaseRunbook, @@ -3336,13 +3381,14 @@ test('documents stable-only semantics and the exact seven-file artifact boundary 'primitree-cli-$VERSION.tgz', 'primitree-hooks-$VERSION.tgz', 'primitree-mcp-$VERSION.tgz', + 'primitree-$VERSION.tgz', 'manifest.json', 'SHA256SUMS', ] assert.deepEqual(extractOrderedCodeList(artifactBoundary), expectedFiles) assert.match( artifactBoundary, - /contains these seven regular,[\s\S]*non-symlink files and no others/i + /contains these eight regular,[\s\S]*non-symlink files and no others/i ) assert.match( artifactBoundary, @@ -3362,7 +3408,7 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = ) const tagPhase = extractMarkdownSubsection( external, - '### 5. Tag, publish, and create the GitHub Release' + '### 5. Tag, publish, and create the GitHub prerelease' ) const checklist = [...external.matchAll(/^- \[([ xX])\] /gm)] assert.equal(checklist.length, 11) @@ -3378,16 +3424,16 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = ) assert.match( tagPhase, - /^- \[ \] Create `v1\.0\.0` at the final verified commit and no other commit, push the single intended tag, and approve publication\.$/m + /^- \[ \] Create `v1\.0\.0-next\.0` at the final verified commit and no other commit, push the single intended tag, and approve publication\.$/m ) assert.doesNotMatch(tagPhase, /^- \[ \] Recreate `v1\.0\.0`/m) for (const phrase of [ '@primitree ownership, 2FA, and new-package rights', 'token-authenticated publish', - 'trusted publishing for all five packages', + 'trusted publishing for all six packages', 'protected npm and GitHub environments and rulesets', 'GitHub environment `npm`', - '`v1.0.0` at the final verified commit and no other commit', + '`v1.0.0-next.0` at the final verified commit and no other commit', 'single intended tag', 'immutable releases', 'release tag updates and deletions', @@ -3432,13 +3478,13 @@ test('freezes same-byte recovery queries and retries in dependency order', () => releaseRunbook, '## Partial publication recovery' ) - const viewCommands = ['core', 'dtcg', 'cli', 'hooks', 'mcp'].map( - packageName => - `npm view "@primitree/${packageName}@$VERSION" version --registry=https://registry.npmjs.org` + const viewCommands = RELEASE_PUBLISH_PACKAGES.map( + ({ name }) => + `npm view "${name}@$VERSION" version --registry=https://registry.npmjs.org` ) - const publishCommands = ['core', 'dtcg', 'cli', 'hooks', 'mcp'].map( - packageName => - `npm publish "$ARTIFACT_DIR/primitree-${packageName}-$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag=latest --ignore-scripts` + const publishCommands = RELEASE_PUBLISH_PACKAGES.map( + ({ stem }) => + `npm publish "$ARTIFACT_DIR/primitree-${stem === 'primitree' ? '' : `${stem}-`}$VERSION.tgz" --registry=https://registry.npmjs.org --access=public --tag="$RELEASE_CHANNEL" --ignore-scripts` ) assertInOrder(recovery, viewCommands, 'partial publication queries') @@ -3473,8 +3519,8 @@ test('documents dist-tag, invalid-content, and immutable artifact recovery', () ) for (const command of [ 'npm dist-tag ls "@primitree/core" --registry=https://registry.npmjs.org', - 'npm dist-tag add "@primitree/core@$VERSION" latest --registry=https://registry.npmjs.org', - 'npm dist-tag rm "@primitree/core" next --registry=https://registry.npmjs.org', + 'npm dist-tag add "@primitree/core@$VERSION" next --registry=https://registry.npmjs.org', + 'npm dist-tag rm "@primitree/core" latest --registry=https://registry.npmjs.org', 'npm deprecate "@primitree/core@$VERSION" "Use 1.0.1; this release contains invalid package contents" --registry=https://registry.npmjs.org', 'RUN_ID=123456789', 'COMMIT_SHA=0123456789abcdef0123456789abcdef01234567', @@ -3510,7 +3556,7 @@ test('preserves tag and provenance boundaries during recovery', () => { assert.match(recovery, /git push origin ":refs\/tags\/v\$VERSION"/) assert.match( recovery, - /pushed wrong tag[\s\S]*publish job never started[\s\S]*all five/i + /pushed wrong tag[\s\S]*publish job never started[\s\S]*all six/i ) assert.match(recovery, /started publish job[\s\S]*blocks tag movement/i) assert.match(recovery, /preserve provenance/i) diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs index ce791329..adf9bb81 100755 --- a/scripts/github-release.mjs +++ b/scripts/github-release.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs' import path from 'node:path' import { pathToFileURL } from 'node:url' import { verifyReleaseArtifacts } from './release-artifacts.mjs' +import { isPrereleaseVersion } from './release-config.mjs' const API_VERSION = '2022-11-28' const REQUEST_TIMEOUT_MS = 10_000 @@ -181,7 +182,9 @@ function assertExactAssetSet(release, expectedAssets) { new Set(release.assets.map(asset => asset?.name)).size !== release.assets.length ) { - throw new Error('GitHub Release must contain exactly seven unique assets') + throw new Error( + `GitHub Release must contain exactly ${expectedAssets.length} unique assets` + ) } if ( release.assets.some(asset => !Number.isInteger(asset?.id)) || @@ -214,7 +217,7 @@ function assertExactRelease(release, expected, expectedAssets, draft) { release?.name !== expected.title || release?.body !== expected.notes || release?.draft !== draft || - release?.prerelease !== false + release?.prerelease !== expected.prerelease ) { throw new Error( draft @@ -313,7 +316,7 @@ function assertDraftMetadata(release, expected) { release?.tag_name !== expected.tag || release?.name !== expected.title || release?.body !== expected.notes || - release?.prerelease !== false + release?.prerelease !== expected.prerelease ) { throw new Error('draft release does not match reviewed metadata') } @@ -554,8 +557,10 @@ export async function createOrResumeGithubRelease({ if (!/^[a-f0-9]{40}$/.test(githubSha ?? '')) { throw new Error('GITHUB_SHA must be a full lowercase commit SHA') } - if (!/^v\d+\.\d+\.\d+$/.test(tag ?? '')) { - throw new Error('GitHub Release tag must be stable vMAJOR.MINOR.PATCH') + if (!/^v\d+\.\d+\.\d+(?:-next\.\d+)?$/.test(tag ?? '')) { + throw new Error( + 'GitHub Release tag must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-next.N' + ) } if (typeof token !== 'string' || token === '') { throw new Error('GITHUB_TOKEN is required') @@ -571,9 +576,10 @@ export async function createOrResumeGithubRelease({ if (tag !== `v${verified.version}`) { throw new Error('GitHub Release tag does not match artifact version') } + const prerelease = isPrereleaseVersion(verified.version) const expectedAssets = localReleaseAssets(artifactDirectory, verified) - if (expectedAssets.length !== 7) { - throw new Error('GitHub Release requires exactly seven local assets') + if (expectedAssets.length !== verified.artifacts.length + 2) { + throw new Error('GitHub Release local asset inventory is incomplete') } await requireRemoteTagCommit({ apiBaseUrl, @@ -599,7 +605,7 @@ export async function createOrResumeGithubRelease({ ) { assertExactRelease( releaseResponse.value, - { notes, tag, title }, + { notes, prerelease, tag, title }, expectedAssets, false ) @@ -632,7 +638,7 @@ export async function createOrResumeGithubRelease({ body: notes, draft: true, name: title, - prerelease: false, + prerelease, tag_name: tag, target_commitish: githubSha, }, @@ -666,7 +672,7 @@ export async function createOrResumeGithubRelease({ tag, token, }) - const expectedMetadata = { notes, tag, title } + const expectedMetadata = { notes, prerelease, tag, title } let assetSnapshot = draftAssetSnapshot(draftState.release) const release = ( await apiRequest(draftState.releaseUrl, { @@ -674,7 +680,7 @@ export async function createOrResumeGithubRelease({ body: notes, draft: true, name: title, - prerelease: false, + prerelease, tag_name: tag, target_commitish: githubSha, }, @@ -757,7 +763,7 @@ export async function createOrResumeGithubRelease({ body: notes, draft: false, name: title, - prerelease: false, + prerelease, tag_name: tag, target_commitish: githubSha, }, diff --git a/scripts/github-release.test.mjs b/scripts/github-release.test.mjs index 88b6403a..65b75598 100644 --- a/scripts/github-release.test.mjs +++ b/scripts/github-release.test.mjs @@ -14,11 +14,13 @@ const SHA = '0123456789abcdef0123456789abcdef01234567' const NOTES = '# Primitree 1.0.0\n\nRelease notes.\n' const REPOSITORY_PATH = '/repos/marklearst/primitree' -function fixtureAssets() { +function fixtureAssets(version = VERSION) { const directory = mkdtempSync(path.join(tmpdir(), 'primitree-release-')) const artifacts = PUBLIC_RELEASE_PACKAGES.map(config => { - const stem = config.name.slice('@primitree/'.length) - const file = `primitree-${stem}-${VERSION}.tgz` + const file = + config.name === 'primitree' + ? `primitree-${version}.tgz` + : `primitree-${config.name.slice('@primitree/'.length)}-${version}.tgz` const bytes = Buffer.from(`${config.name}\n`) writeFileSync(path.join(directory, file), bytes) return { @@ -29,7 +31,7 @@ function fixtureAssets() { }) writeFileSync( path.join(directory, 'manifest.json'), - `${JSON.stringify({ version: VERSION, artifacts }, null, 2)}\n` + `${JSON.stringify({ version, artifacts }, null, 2)}\n` ) writeFileSync( path.join(directory, 'SHA256SUMS'), @@ -38,11 +40,12 @@ function fixtureAssets() { return { directory } } -function localAssets(directory) { +function localAssets(directory, version = VERSION) { return [ ...PUBLIC_RELEASE_PACKAGES.map(config => { - const stem = config.name.slice('@primitree/'.length) - return `primitree-${stem}-${VERSION}.tgz` + return config.name === 'primitree' + ? `primitree-${version}.tgz` + : `primitree-${config.name.slice('@primitree/'.length)}-${version}.tgz` }), 'manifest.json', 'SHA256SUMS', @@ -99,6 +102,7 @@ async function startGithubApi({ mutateUploadResponse, redirectUploadTo, uploadUrl, + tag = TAG, }) { const requests = [] let releases = structuredClone(initialReleases) @@ -157,7 +161,7 @@ async function startGithubApi({ if ( request.method === 'GET' && - url.pathname === `${REPOSITORY_PATH}/git/ref/tags/${TAG}` + url.pathname === `${REPOSITORY_PATH}/git/ref/tags/${tag}` ) { tagReads += 1 const resolvedTagSha = @@ -166,10 +170,10 @@ async function startGithubApi({ } if ( request.method === 'GET' && - url.pathname === `${REPOSITORY_PATH}/releases/tags/${TAG}` + url.pathname === `${REPOSITORY_PATH}/releases/tags/${tag}` ) { const published = releases.find( - release => release.tag_name === TAG && release.draft === false + release => release.tag_name === tag && release.draft === false ) if (published === undefined) return json(404, { message: 'Not Found' }) const finalValue = @@ -188,7 +192,7 @@ async function startGithubApi({ const live = findRelease(entry.id) return live === undefined ? entry : structuredClone(live) }) - const exactIndex = value.findIndex(release => release.tag_name === TAG) + const exactIndex = value.findIndex(release => release.tag_name === tag) if (exactIndex !== -1) { draftSnapshots += 1 const exact = value[exactIndex] @@ -235,7 +239,7 @@ async function startGithubApi({ name: input.name, body: input.body, draft: true, - prerelease: false, + prerelease: input.prerelease, assets: [], upload_url: releaseUploadUrl(nextReleaseId - 1), } @@ -431,6 +435,47 @@ test('creates and publishes a first release after list confirms no draft', async }) }) +test('publishes a next tag as a GitHub prerelease with the complete asset set', async () => { + const version = '1.0.0-next.0' + const tag = `v${version}` + const fixture = fixtureAssets(version) + const api = await startGithubApi({ fixture, tag }) + try { + const result = await createOrResumeGithubRelease({ + apiBaseUrl: api.baseUrl, + artifactDirectory: fixture.directory, + githubRepository: 'marklearst/primitree', + githubSha: SHA, + notes: '# Primitree 1.0.0-next.0\n\nPrerelease notes.\n', + tag, + title: 'Primitree v1.0.0-next.0', + token: 'local-test-token', + }) + + assert.equal(result.status, 'published') + assert.equal(api.releases[0].prerelease, true) + assert.equal( + api.releases[0].assets.length, + localAssets(fixture.directory, version).length + ) + assert.ok( + api.requests + .filter( + request => + request.method === 'PATCH' || + (request.method === 'POST' && + request.path === `${REPOSITORY_PATH}/releases`) + ) + .map(request => JSON.parse(request.body)) + .filter(body => Object.hasOwn(body, 'prerelease')) + .every(body => body.prerelease === true) + ) + } finally { + await api.close() + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + test('resumes a partial draft when the API reports the existing tag target as main', async () => { await withGithubApi( fixture => ({ @@ -452,7 +497,10 @@ test('resumes a partial draft when the API reports the existing tag target as ma ) assert.equal(api.releases[0].draft, false) assert.equal(api.releases[0].target_commitish, 'main') - assert.equal(api.releases[0].assets.length, 7) + assert.equal( + api.releases[0].assets.length, + localAssets(fixture.directory).length + ) assert.deepEqual( api.releases[0].assets.slice(0, 2).map(asset => asset.id), [41, 42] @@ -463,7 +511,7 @@ test('resumes a partial draft when the API reports the existing tag target as ma request.method === 'POST' && /\/releases\/\d+\/assets$/.test(request.path) ).length, - 5 + localAssets(fixture.directory).length - 2 ) const patches = api.requests.filter(request => request.method === 'PATCH') assert.equal(patches.length, 2) diff --git a/scripts/prerelease-contract.test.mjs b/scripts/prerelease-contract.test.mjs new file mode 100644 index 00000000..baded802 --- /dev/null +++ b/scripts/prerelease-contract.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { + isPrereleaseVersion, + releaseChannelForVersion, +} from './release-config.mjs' + +const root = new URL('../', import.meta.url) +const packageNames = [ + '@primitree/core', + '@primitree/dtcg', + '@primitree/cli', + '@primitree/hooks', + '@primitree/mcp', + 'primitree', +] + +function readJson(relativePath) { + return JSON.parse(readFileSync(new URL(relativePath, root), 'utf8')) +} + +function readLaunchChangeset() { + const source = readFileSync( + new URL('.changeset/primitree-one.md', root), + 'utf8' + ) + const frontmatter = source.match(/^---\n([\s\S]*?)\n---\n/u)?.[1] + assert.notEqual(frontmatter, undefined) + return [ + ...frontmatter.matchAll(/^['"]([^'"]+)['"]: (major|minor|patch)$/gmu), + ].map(match => ({ name: match[1], type: match[2] })) +} + +test('coordinates the first next prerelease through Changesets', () => { + const rootManifest = readJson('package.json') + const changesets = readJson('.changeset/config.json') + const prerelease = readJson('.changeset/pre.json') + const releases = readLaunchChangeset() + + assert.equal(rootManifest.name, 'primitree-workspace') + assert.deepEqual(changesets.fixed, [packageNames]) + assert.equal(prerelease.mode, 'pre') + assert.equal(prerelease.tag, 'next') + assert.deepEqual(prerelease.changesets, ['primitree-one']) + assert.deepEqual( + Object.keys(prerelease.initialVersions).sort(), + [...packageNames].sort() + ) + assert.ok( + Object.values(prerelease.initialVersions).every( + version => version === '0.0.0' + ) + ) + for (const relativePath of [ + 'packages/core/package.json', + 'packages/dtcg/package.json', + 'packages/cli/package.json', + 'packages/hooks/package.json', + 'packages/mcp/package.json', + 'packages/primitree/package.json', + ]) { + assert.equal(readJson(relativePath).version, '1.0.0-next.0') + } + assert.deepEqual( + releases, + packageNames.map(name => ({ name, type: 'major' })) + ) +}) + +test('maps only stable and next versions to public release channels', () => { + assert.equal(releaseChannelForVersion('1.0.0'), 'latest') + assert.equal(releaseChannelForVersion('1.0.0-next.0'), 'next') + assert.equal(isPrereleaseVersion('1.0.0'), false) + assert.equal(isPrereleaseVersion('1.0.0-next.0'), true) + assert.throws( + () => releaseChannelForVersion('1.0.0-beta.1'), + /release version/i + ) + assert.throws(() => releaseChannelForVersion('v1.0.0'), /release version/i) +}) diff --git a/scripts/release-artifacts.mjs b/scripts/release-artifacts.mjs index 49fb1945..37342fd8 100644 --- a/scripts/release-artifacts.mjs +++ b/scripts/release-artifacts.mjs @@ -17,9 +17,13 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { spawnSync } from 'node:child_process' import { fileURLToPath, pathToFileURL } from 'node:url' -import { PUBLIC_RELEASE_PACKAGES } from './release-config.mjs' +import { + PUBLIC_RELEASE_PACKAGES, + releaseChannelForVersion, +} from './release-config.mjs' -const RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ +const RELEASE_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-next\.(0|[1-9]\d*))?$/ const SHA256_PATTERN = /^[a-f0-9]{64}$/ const MANIFEST_NAME = 'manifest.json' const CHECKSUMS_NAME = 'SHA256SUMS' @@ -173,7 +177,10 @@ export function expectedArtifacts(version) { } return PUBLIC_RELEASE_PACKAGES.map(config => ({ name: config.name, - file: `primitree-${config.name.slice('@primitree/'.length)}-${version}.tgz`, + file: + config.name === 'primitree' + ? `primitree-${version}.tgz` + : `primitree-${config.name.slice('@primitree/'.length)}-${version}.tgz`, })) } @@ -691,7 +698,7 @@ export function packReleaseArtifacts() { } } -export function npmPublishDryRunArgs(artifactPath) { +export function npmPublishDryRunArgs(artifactPath, version) { if (typeof artifactPath !== 'string' || !path.isAbsolute(artifactPath)) { throw new Error('npm publish dry-run requires an absolute tarball path') } @@ -702,7 +709,7 @@ export function npmPublishDryRunArgs(artifactPath) { '--offline', '--provenance=false', '--access=public', - '--tag=latest', + `--tag=${releaseChannelForVersion(version)}`, '--ignore-scripts', '--registry=https://registry.npmjs.org/', ] @@ -755,7 +762,11 @@ function runExternalReleaseChecks(before) { commonOptions ) } - spawnChecked('npm', npmPublishDryRunArgs(artifact.path), commonOptions) + spawnChecked( + 'npm', + npmPublishDryRunArgs(artifact.path, before.version), + commonOptions + ) } const consumerDirectory = path.join( @@ -853,7 +864,9 @@ export function verifyReleaseArtifacts({ artifactDirectory } = {}) { } } if (directoryEntries.length !== PUBLIC_RELEASE_PACKAGES.length + 2) { - throw new Error('artifact directory must contain exactly seven entries') + throw new Error( + `artifact directory must contain exactly ${expectedEntries.size} entries` + ) } if (checksumBytes.toString('utf8') !== canonicalChecksums(artifacts)) { diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs index d71e7fee..3d9ac64e 100644 --- a/scripts/release-artifacts.test.mjs +++ b/scripts/release-artifacts.test.mjs @@ -28,6 +28,7 @@ const EXPECTED_NAMES = [ '@primitree/cli', '@primitree/hooks', '@primitree/mcp', + 'primitree', ] const EXPECTED_FILES = [ 'primitree-core-1.0.0.tgz', @@ -35,6 +36,7 @@ const EXPECTED_FILES = [ 'primitree-cli-1.0.0.tgz', 'primitree-hooks-1.0.0.tgz', 'primitree-mcp-1.0.0.tgz', + 'primitree-1.0.0.tgz', ] const SCRIPT_PATH = fileURLToPath( new URL('./release-artifacts.mjs', import.meta.url) @@ -408,9 +410,13 @@ function makePackRepository(t) { ) for (const name of EXPECTED_NAMES) { - const packageName = name.slice('@primitree/'.length) + const packageName = + name === 'primitree' ? name : name.slice('@primitree/'.length) const packageDirectory = path.join(directory, 'packages', packageName) - mkdirSync(path.join(packageDirectory, 'dist'), { recursive: true }) + mkdirSync( + path.join(packageDirectory, name === 'primitree' ? 'bin' : 'dist'), + { recursive: true } + ) writeFileSync( path.join(packageDirectory, 'package.json'), `${JSON.stringify({ name, version: VERSION })}\n` @@ -489,6 +495,16 @@ test('derives the expected artifacts in dependency order', async () => { file: EXPECTED_FILES[index], })) ) + assert.deepEqual( + expectedArtifacts('1.0.0-next.0'), + EXPECTED_NAMES.map(name => ({ + name, + file: + name === 'primitree' + ? 'primitree-1.0.0-next.0.tgz' + : `primitree-${name.slice('@primitree/'.length)}-1.0.0-next.0.tgz`, + })) + ) assert.throws(() => expectedArtifacts('v1.0.0'), /MAJOR\.MINOR\.PATCH/) assert.throws(() => expectedArtifacts('1.0.0-beta.1'), /MAJOR\.MINOR\.PATCH/) }) @@ -501,7 +517,7 @@ test('constructs stable public npm publish dry-run arguments', async () => { EXPECTED_FILES[0] ) - assert.deepEqual(npmPublishDryRunArgs(artifactPath), [ + assert.deepEqual(npmPublishDryRunArgs(artifactPath, VERSION), [ 'publish', artifactPath, '--dry-run', @@ -512,8 +528,19 @@ test('constructs stable public npm publish dry-run arguments', async () => { '--ignore-scripts', '--registry=https://registry.npmjs.org/', ]) + assert.deepEqual(npmPublishDryRunArgs(artifactPath, '1.0.0-next.0'), [ + 'publish', + artifactPath, + '--dry-run', + '--offline', + '--provenance=false', + '--access=public', + '--tag=next', + '--ignore-scripts', + '--registry=https://registry.npmjs.org/', + ]) assert.throws( - () => npmPublishDryRunArgs(EXPECTED_FILES[0]), + () => npmPublishDryRunArgs(EXPECTED_FILES[0], VERSION), /absolute tarball path/i ) }) @@ -664,7 +691,7 @@ test('artifact consumer fails closed before install on registry mismatch', t => } }) -test('accepts exactly five ordered artifacts and performs no writes', async t => { +test('accepts exactly six ordered artifacts and performs no writes', async t => { const fixture = makeFixture(t) const before = new Map( ['manifest.json', 'SHA256SUMS', ...EXPECTED_FILES].map(file => [ @@ -695,7 +722,7 @@ test('accepts exactly five ordered artifacts and performs no writes', async t => } }) -test('release checks require all seven artifact files to remain byte-identical', async t => { +test('release checks require all eight artifact files to remain byte-identical', async t => { const fixture = makeFixture(t) const { checkReleaseArtifacts, verifyReleaseArtifacts } = await releaseArtifactsModule() @@ -906,9 +933,9 @@ test('rejects malformed manifest metadata', async t => { /version.*MAJOR\.MINOR\.PATCH/i, ], [ - 'prerelease version', + 'unsupported prerelease version', f => { - f.manifest.version = '1.0.0-next.1' + f.manifest.version = '1.0.0-beta.1' f.rewriteManifest() }, /version.*MAJOR\.MINOR\.PATCH/i, @@ -976,7 +1003,7 @@ test('rejects wrong artifact entry count and shape', async t => { f.manifest.artifacts.pop() f.rewriteManifest() }, - /exactly 5 artifact entries/i, + /exactly 6 artifact entries/i, ], [ 'extra entry', @@ -984,7 +1011,7 @@ test('rejects wrong artifact entry count and shape', async t => { f.manifest.artifacts.push({ ...f.manifest.artifacts[0] }) f.rewriteManifest() }, - /exactly 5 artifact entries/i, + /exactly 6 artifact entries/i, ], [ 'null entry', diff --git a/scripts/release-config.mjs b/scripts/release-config.mjs index 70443654..b683ad35 100644 --- a/scripts/release-config.mjs +++ b/scripts/release-config.mjs @@ -62,6 +62,28 @@ export const RELEASE_FUNDING_TYPE = 'github' export const RELEASE_FUNDING = 'https://github.com/sponsors/marklearst' export const RELEASE_NODE_ENGINE = '>=24.0.0' +const STABLE_RELEASE_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ +const NEXT_RELEASE_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-next\.(0|[1-9]\d*)$/ + +export function releaseChannelForVersion(version) { + if (typeof version !== 'string') { + throw new Error( + 'release version must use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N' + ) + } + if (STABLE_RELEASE_VERSION_PATTERN.test(version)) return 'latest' + if (NEXT_RELEASE_VERSION_PATTERN.test(version)) return 'next' + throw new Error( + 'release version must use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N' + ) +} + +export function isPrereleaseVersion(version) { + return releaseChannelForVersion(version) === 'next' +} + export const PUBLIC_RELEASE_PACKAGES = Object.freeze([ freezePackage({ path: 'packages/core', @@ -148,6 +170,7 @@ export const PUBLIC_RELEASE_PACKAGES = Object.freeze([ requiredFiles: ['dist', 'CHANGELOG.md'], requiredDeclarationFiles: ['dist/index.d.ts', 'dist/config.d.ts'], expectedExports: { + './bin': './dist/index.js', './config': { types: './dist/config.d.ts', import: './dist/config.js', @@ -200,4 +223,16 @@ export const PUBLIC_RELEASE_PACKAGES = Object.freeze([ requiredBinTarget: './dist/cli.js', requiredInternalRuntimeDependencies: ['@primitree/core', '@primitree/dtcg'], }), + freezePackage({ + path: 'packages/primitree', + manifestPath: 'packages/primitree/package.json', + name: 'primitree', + attwProfile: null, + requiredFiles: ['bin', 'CHANGELOG.md'], + requiredDeclarationFiles: [], + expectedExports: undefined, + requiredBin: 'primitree', + requiredBinTarget: './bin/primitree.js', + requiredInternalRuntimeDependencies: ['@primitree/cli'], + }), ]) diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index 142a011d..2a2c2be0 100755 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -14,7 +14,11 @@ import path from 'node:path' import { spawnSync } from 'node:child_process' import { pathToFileURL } from 'node:url' import { verifyReleaseArtifacts } from './release-artifacts.mjs' -import { PUBLIC_RELEASE_PACKAGES } from './release-config.mjs' +import { + PUBLIC_RELEASE_PACKAGES, + isPrereleaseVersion, + releaseChannelForVersion, +} from './release-config.mjs' export const PUBLIC_NPM_REGISTRY = 'https://registry.npmjs.org/' export const REQUIRED_NPM_VERSION = '11.18.0' @@ -84,8 +88,10 @@ export function verifyExactMain({ githubSha, runCommand = defaultRunCommand, }) { - if (!/^refs\/tags\/v\d+\.\d+\.\d+$/.test(githubRef ?? '')) { - throw new Error('release ref must be a stable vMAJOR.MINOR.PATCH tag') + if (!/^refs\/tags\/v\d+\.\d+\.\d+(?:-next\.\d+)?$/.test(githubRef ?? '')) { + throw new Error( + 'release ref must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-next.N' + ) } if (!/^[a-f0-9]{40}$/.test(githubSha ?? '')) { throw new Error('GITHUB_SHA must be a full lowercase commit SHA') @@ -226,12 +232,21 @@ function validateRegistryMetadata({ artifactPath, metadata, expected }) { attestations?.provenance, `${label}: provenance metadata is invalid` ) + const releaseChannel = releaseChannelForVersion(expected.version) complete = validateAvailableField( - distTags?.latest, + distTags?.[releaseChannel], expected.version, - `${label}: latest dist-tag mismatch` + `${label}: ${releaseChannel} dist-tag mismatch` ) && complete + if ( + expected.forbidPrereleaseLatest === true && + distTags?.latest === expected.version + ) { + throw new Error( + `${label}: prerelease must not remain on the latest dist-tag` + ) + } complete = validateAvailableField( dist?.integrity, @@ -492,6 +507,7 @@ async function inspectRegistryPackage({ runCommand, tagRef, version, + forbidPrereleaseLatest = false, }) { const result = runCommand( 'npm', @@ -516,6 +532,7 @@ async function inspectRegistryPackage({ tagRef, commitSha, registry, + forbidPrereleaseLatest, } const metadataState = validateRegistryMetadata({ artifactPath: artifact.path, @@ -570,13 +587,13 @@ function requireReleaseContext(version, githubRef, githubSha) { } } -function publishArguments(artifactPath, registry) { +export function releasePublishArguments(artifactPath, registry, version) { return [ 'publish', artifactPath, '--provenance', '--access=public', - '--tag=latest', + `--tag=${releaseChannelForVersion(version)}`, '--ignore-scripts', `--registry=${registry}`, '--fetch-retries=0', @@ -652,7 +669,11 @@ export async function runReleasePublish({ requireCommandSuccess( runCommand( 'npm', - publishArguments(artifact.path, registry), + releasePublishArguments( + artifact.path, + registry, + verified.version + ), commandOptions(commandContext, PUBLISH_COMMAND_TIMEOUT_MS) ), `npm publish ${artifact.name}@${verified.version}` @@ -668,6 +689,44 @@ export async function runReleasePublish({ } } + if (isPrereleaseVersion(verified.version)) { + for (const artifact of verified.artifacts) { + const state = await inspectRegistryPackage({ + artifact, + commandContext, + commitSha: githubSha, + fetchJson, + registry, + runCommand, + tagRef: githubRef, + version: verified.version, + }) + if (state.kind !== 'present') { + throw new Error( + `prerelease dist-tag inspection failed for ${artifact.name}: ${state.kind}` + ) + } + if (state.metadata?.['dist-tags']?.latest === verified.version) { + requireCommandSuccess( + runCommand( + 'npm', + [ + 'dist-tag', + 'rm', + artifact.name, + 'latest', + `--registry=${registry}`, + '--fetch-retries=0', + `--fetch-timeout=${FETCH_TIMEOUT_MS}`, + ], + commandOptions(commandContext) + ), + `npm dist-tag rm ${artifact.name} latest` + ) + } + } + } + for (const artifact of verified.artifacts) { const state = await inspectRegistryPackage({ artifact, @@ -678,6 +737,7 @@ export async function runReleasePublish({ runCommand, tagRef: githubRef, version: verified.version, + forbidPrereleaseLatest: isPrereleaseVersion(verified.version), }) if (state.kind !== 'present') { throw new Error( @@ -1305,9 +1365,7 @@ export async function runPublicRegistryConsumer({ registry = PUBLIC_NPM_REGISTRY, runCommand = defaultRunCommand, }) { - if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version ?? '')) { - throw new Error('public consumer version must be stable MAJOR.MINOR.PATCH') - } + releaseChannelForVersion(version) const commandContext = createNpmExecutionContext({ environment, keepOidc: false, diff --git a/scripts/release-publish.test.mjs b/scripts/release-publish.test.mjs index 75e21bea..1a37e4b4 100644 --- a/scripts/release-publish.test.mjs +++ b/scripts/release-publish.test.mjs @@ -20,6 +20,7 @@ import { runPackedTarballConsumer, runPublicRegistryConsumer, runReleasePublish, + releasePublishArguments, validatePublishedPackage, verifyExactMain, } from './release-publish.mjs' @@ -31,6 +32,12 @@ const TAG_REF = `refs/tags/v${VERSION}` const REPOSITORY = 'https://github.com/marklearst/primitree' const WORKFLOW_PATH = '.github/workflows/ci.yml' +function artifactFile(name, version = VERSION) { + return name === 'primitree' + ? `primitree-${version}.tgz` + : `primitree-${name.slice('@primitree/'.length)}-${version}.tgz` +} + function writeInstalledPackageDocumentation(consumerDirectory, packageName) { const packageDirectory = path.join( consumerDirectory, @@ -46,12 +53,11 @@ function writeInstalledPackageDocumentation(consumerDirectory, packageName) { return packageDirectory } -function fixtureArtifacts() { +function fixtureArtifacts(version = VERSION) { const directory = mkdtempSync(path.join(tmpdir(), 'primitree-publish-')) const artifacts = PUBLIC_RELEASE_PACKAGES.map(config => { - const stem = config.name.slice('@primitree/'.length) - const file = `primitree-${stem}-${VERSION}.tgz` - const bytes = Buffer.from(`${config.name} ${VERSION}\n`) + const file = artifactFile(config.name, version) + const bytes = Buffer.from(`${config.name} ${version}\n`) writeFileSync(path.join(directory, file), bytes) return { name: config.name, @@ -61,7 +67,7 @@ function fixtureArtifacts() { }) writeFileSync( path.join(directory, 'manifest.json'), - `${JSON.stringify({ version: VERSION, artifacts }, null, 2)}\n` + `${JSON.stringify({ version, artifacts }, null, 2)}\n` ) writeFileSync( path.join(directory, 'SHA256SUMS'), @@ -70,12 +76,13 @@ function fixtureArtifacts() { return { artifacts, directory } } -function statementFor(name, bytes, overrides = {}) { +function statementFor(name, bytes, overrides = {}, version = VERSION) { + const tagRef = `refs/tags/v${version}` const statement = { _type: 'https://in-toto.io/Statement/v1', subject: [ { - name: `pkg:npm/${name.replace('@', '%40')}@${VERSION}`, + name: `pkg:npm/${name.replace('@', '%40')}@${version}`, digest: { sha512: createHash('sha512').update(bytes).digest('hex'), }, @@ -88,12 +95,12 @@ function statementFor(name, bytes, overrides = {}) { workflow: { repository: REPOSITORY, path: WORKFLOW_PATH, - ref: TAG_REF, + ref: tagRef, }, }, resolvedDependencies: [ { - uri: `git+${REPOSITORY}@${TAG_REF}`, + uri: `git+${REPOSITORY}@${tagRef}`, digest: { gitCommit: SHA }, }, ], @@ -104,8 +111,8 @@ function statementFor(name, bytes, overrides = {}) { return statement } -function attestationFor(name, bytes, overrides = {}) { - const statement = statementFor(name, bytes, overrides) +function attestationFor(name, bytes, overrides = {}, version = VERSION) { + const statement = statementFor(name, bytes, overrides, version) return { attestations: [ { @@ -121,30 +128,41 @@ function attestationFor(name, bytes, overrides = {}) { } } -function metadataFor(name, bytes, registry = PUBLIC_NPM_REGISTRY) { +function metadataFor( + name, + bytes, + registry = PUBLIC_NPM_REGISTRY, + version = VERSION, + distTags = { latest: version } +) { const encodedName = name.replace('/', '%2f') return { name, - version: VERSION, - 'dist-tags': { latest: VERSION }, + version, + 'dist-tags': distTags, dist: { integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, attestations: { - url: `${registry}-/npm/v1/attestations/${encodedName}@${VERSION}`, + url: `${registry}-/npm/v1/attestations/${encodedName}@${version}`, provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, }, }, } } -function fakePublishedPackage(directory, name) { - const stem = name.slice('@primitree/'.length) - const artifactPath = path.join(directory, `primitree-${stem}-${VERSION}.tgz`) +function fakePublishedPackage( + directory, + name, + version = VERSION, + distTags = { latest: version } +) { + const file = artifactFile(name, version) + const artifactPath = path.join(directory, file) const bytes = readFileSync(artifactPath) return { artifactPath, - attestation: attestationFor(name, bytes), - metadata: metadataFor(name, bytes), + attestation: attestationFor(name, bytes, {}, version), + metadata: metadataFor(name, bytes, PUBLIC_NPM_REGISTRY, version, distTags), } } @@ -483,6 +501,105 @@ test('freshly fetches origin main and requires tag, main, and GITHUB_SHA equalit ) }) +test('uses the npm channel selected by the exact release version', () => { + const artifactPath = path.join(tmpdir(), 'primitree-1.0.0-next.0.tgz') + + assert.ok( + releasePublishArguments( + artifactPath, + PUBLIC_NPM_REGISTRY, + '1.0.0-next.0' + ).includes('--tag=next') + ) + assert.ok( + releasePublishArguments( + artifactPath, + PUBLIC_NPM_REGISTRY, + '1.0.0' + ).includes('--tag=latest') + ) +}) + +test('removes an accidental latest tag before accepting a first prerelease', async () => { + const version = '1.0.0-next.0' + const fixture = fixtureArtifacts(version) + const metadataByName = new Map() + const attestationByUrl = new Map() + const calls = [] + for (const config of PUBLIC_RELEASE_PACKAGES) { + const published = fakePublishedPackage( + fixture.directory, + config.name, + version, + { latest: version, next: version } + ) + metadataByName.set(config.name, published.metadata) + attestationByUrl.set( + published.metadata.dist.attestations.url, + published.attestation + ) + } + + try { + const result = await runReleasePublish({ + artifactDirectory: fixture.directory, + environment: { ...process.env, NPM_TOKEN: 'bootstrap-token' }, + githubRef: `refs/tags/v${version}`, + githubSha: SHA, + retryDelayMs: 0, + sleep: async () => {}, + fetchJson: async url => ({ + status: 200, + value: attestationByUrl.get(url), + }), + runCommand(command, args, options = {}) { + calls.push([command, ...args]) + const configResult = registryConfigResult(args, options) + if (configResult !== undefined) return configResult + if (args[0] === '--version') { + return { status: 0, stdout: '11.18.0\n', stderr: '' } + } + if (args[0] === 'view') { + const spec = args[1] + const name = spec.slice(0, spec.lastIndexOf('@')) + return { + status: 0, + stdout: JSON.stringify(metadataByName.get(name)), + stderr: '', + } + } + if (args[0] === 'dist-tag' && args[1] === 'rm') { + delete metadataByName.get(args[2])['dist-tags'].latest + return { status: 0, stdout: '', stderr: '' } + } + throw new Error(`unexpected command ${command} ${args.join(' ')}`) + }, + }) + + assert.equal(result.mode, 'bootstrap') + assert.deepEqual( + calls + .filter(call => call[1] === 'dist-tag') + .map(call => call.slice(1, 5)), + PUBLIC_RELEASE_PACKAGES.map(config => [ + 'dist-tag', + 'rm', + config.name, + 'latest', + ]) + ) + assert.equal( + calls.some(call => call[1] === 'publish'), + false + ) + for (const metadata of metadataByName.values()) { + assert.deepEqual(metadata['dist-tags'], { next: version }) + } + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + test('validates one exact identity-aware SLSA provenance statement', () => { const fixture = fixtureArtifacts() try { From 245a2e9b4e39e248e11560aa37bc72f1f940e8f0 Mon Sep 17 00:00:00 2001 From: Mark Learst Date: Sun, 16 Aug 2026 10:41:49 -0400 Subject: [PATCH 2/4] fix(release): harden prerelease verification --- .changeset/README.md | 4 +- .github/workflows/ci.yml | 7 +- docs/releasing.md | 33 ++++-- package.json | 2 +- packages/primitree/README.md | 4 +- scripts/check-release.mjs | 8 +- scripts/check-release.test.mjs | 18 ++- scripts/release-artifacts.mjs | 18 +-- scripts/release-artifacts.test.mjs | 18 ++- scripts/release-publish.mjs | 157 ++++++++++++++++++------- scripts/release-publish.test.mjs | 119 ++++++++++++++++++- tests/e2e/docs-home-governance.spec.ts | 4 + 12 files changed, 305 insertions(+), 87 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index 10edbbfd..990afde6 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -2,7 +2,7 @@ Create a release note with `pnpm changeset`. The five scoped packages and the unscoped `primitree` launcher use a fixed version group, so an approved -changeset updates all six to the same version. Apply pending notes with `pnpm -version-packages`. +changeset updates all six to the same version. Run `pnpm version-packages` to +apply pending notes. Maintainers do not version or publish private workspaces through Changesets. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c68c531..6dab527e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,12 +93,7 @@ jobs: uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: use_oidc: true - files: >- - ./packages/core/coverage/lcov.info, - ./packages/dtcg/coverage/lcov.info, - ./packages/cli/coverage/lcov.info, - ./packages/hooks/coverage/lcov.info, - ./packages/mcp/coverage/lcov.info + files: ./packages/core/coverage/lcov.info,./packages/dtcg/coverage/lcov.info,./packages/cli/coverage/lcov.info,./packages/hooks/coverage/lcov.info,./packages/mcp/coverage/lcov.info disable_search: true fail_ci_if_error: true diff --git a/docs/releasing.md b/docs/releasing.md index 80388e87..f817b7e5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -60,10 +60,10 @@ must be the exact version prefixed with `v`. Packages publish in dependency order: core → dtcg → cli → hooks → mcp → primitree. Stable versions use npm's `latest` dist-tag. Versions ending in -`-next.N` use `next` and GitHub marks their release as a prerelease. A first npm -publication can assign both `next` and `latest`; the release controller removes -that accidental `latest` tag from every prerelease package and verifies that -`next` identifies the exact version before continuing. +`-next.N` use `next` and GitHub marks their release as a prerelease. The release +controller also removes an unexpected `latest` tag from every prerelease +package and verifies that `next` identifies the exact version before +continuing. ## Release artifact boundary @@ -102,11 +102,14 @@ npm token, and does not request an OIDC identity token. It applies `changeset version`, updates the lockfile without lifecycle scripts, and proves the result with a frozen, script-free install. -The initial `1.0.0-next.0` release uses the reviewed launch changeset and -`.changeset/pre.json`. Merge the resulting Changesets version pull request after -its exact head passes the protected checks. Keep prerelease mode active -for later `next` releases. Exit prerelease mode in a separate reviewed version -pull request before publishing stable `1.0.0`. +The launcher pull request already contains the reviewed launch changeset, +`.changeset/pre.json`, aligned `1.0.0-next.0` package versions, lockfile, and +changelogs. It is the initial versioned release candidate. After it merges, the +Version Packages workflow has no second initial Changesets version pull request +to open. Keep prerelease mode active for later `next` releases; +each later releasable package change requires a reviewed changeset and the +resulting version pull request. Exit prerelease mode in a separate reviewed +version pull request before publishing stable `1.0.0`. Before enabling the workflow, verify the repository setting **Allow GitHub Actions to create and approve pull requests**. Run this check with a repository @@ -1139,13 +1142,19 @@ npm publish "$ARTIFACT_DIR/primitree-$VERSION.tgz" --registry=https://registry.n ### Wrong dist-tag Inspect and repair a dist-tag without republishing. A prerelease must remain on -`next` and must not occupy `latest`. Use this core example for the package whose -dist-tag needs repair: +`next` and must not occupy `latest`. Remove `latest` when the listing shows that +it points to `$VERSION`; preserve it when it points to a stable version. +Use this core example for the package whose dist-tag needs repair: ```bash npm dist-tag ls "@primitree/core" --registry=https://registry.npmjs.org npm dist-tag add "@primitree/core@$VERSION" next --registry=https://registry.npmjs.org -npm dist-tag rm "@primitree/core" latest --registry=https://registry.npmjs.org +LATEST_VERSION="$( + npm view "@primitree/core" dist-tags.latest --registry=https://registry.npmjs.org +)" +if [ "$LATEST_VERSION" = "$VERSION" ]; then + npm dist-tag rm "@primitree/core" latest --registry=https://registry.npmjs.org +fi ``` ### Bad package contents diff --git a/package.json b/package.json index 64c514de..8bf1b1ac 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packageManager": "pnpm@11.10.0", "scripts": { "build": "turbo run build", - "test": "node --test scripts/check-brand.test.mjs scripts/check-release.test.mjs scripts/check-prose.test.mjs scripts/prose/rules.test.mjs && pnpm run check:prose && pnpm run test:release-artifacts && pnpm run test:release-workflow && turbo run test", + "test": "node --test scripts/check-brand.test.mjs scripts/check-release.test.mjs scripts/check-prose.test.mjs scripts/prerelease-contract.test.mjs scripts/prose/rules.test.mjs && pnpm run check:prose && pnpm run test:release-artifacts && pnpm run test:release-workflow && turbo run test", "test:release-artifacts": "node --test scripts/release-artifacts.test.mjs", "test:release-workflow": "node --test scripts/release-publish.test.mjs scripts/github-release.test.mjs", "test:e2e": "playwright test", diff --git a/packages/primitree/README.md b/packages/primitree/README.md index 3b8bb9de..b9ea7f9b 100644 --- a/packages/primitree/README.md +++ b/packages/primitree/README.md @@ -8,8 +8,8 @@ npx primitree@next --help npx primitree@next check ``` -The `next` tag carries the 1.0 prerelease. Npm's `latest` tag stays unchanged. -The stable release will support `npx primitree` without a tag. +The `next` tag carries the 1.0 prerelease. The prerelease does not occupy npm's +`latest` tag. The stable release will support `npx primitree` without a tag. Install `@primitree/cli@next` when a project imports `@primitree/cli/config`. The launcher contains no second CLI implementation; it diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index cba7267d..91b32fa8 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -519,7 +519,9 @@ export function validateReleaseManifests(options) { if (versions.size !== 1 || typeof version !== 'string') { errors.push('all public packages must use one version') } else if (!RELEASE_VERSION_PATTERN.test(version)) { - errors.push(`package version ${version} must use MAJOR.MINOR.PATCH`) + errors.push( + `package version ${version} must use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N` + ) } for (const input of privatePackages) { @@ -564,7 +566,9 @@ export function validateReleaseManifests(options) { : tag === null ? '' : `<${typeof tag}>` - errors.push(`release tag ${tagDescription} must use vMAJOR.MINOR.PATCH`) + errors.push( + `release tag ${tagDescription} must use vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-next.N` + ) } else if (typeof version === 'string' && tag !== `v${version}`) { errors.push(`tag ${tag} does not match package version ${version}`) } diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs index 7c939e1c..f986f554 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -266,7 +266,7 @@ function assertWorkflowTrustPolicy(source) { './packages/cli/coverage/lcov.info', './packages/hooks/coverage/lcov.info', './packages/mcp/coverage/lcov.info', - ].join(', '), + ].join(','), disable_search: true, fail_ci_if_error: true, }) @@ -1212,11 +1212,11 @@ test('rejects wrong names, license copies, versions, and tags', () => { })), tag: undefined, }), - /must use MAJOR\.MINOR\.PATCH/ + /must use MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-next\.N/ ) assert.throws( () => validate({ tag: 'release-1.0.0' }), - /vMAJOR\.MINOR\.PATCH/ + /vMAJOR\.MINOR\.PATCH or vMAJOR\.MINOR\.PATCH-next\.N/ ) assert.throws(() => validate({ tag: 'v5.0.1' }), /does not match/) }) @@ -1439,6 +1439,10 @@ test('runs the focused release workflow helpers in the root test command', () => 'node --test scripts/release-publish.test.mjs scripts/github-release.test.mjs' ) assert.match(rootManifest.scripts.test, /pnpm run test:release-workflow/) + assert.match( + rootManifest.scripts.test, + /scripts\/prerelease-contract\.test\.mjs/ + ) }) test('versions packages and proves the synchronized lockfile without lifecycle scripts', () => { @@ -2050,7 +2054,11 @@ test('documents exact contributor and version pull request boundaries', () => { ) assert.match( versionPullRequests, - /initial `1\.0\.0-next\.0`[\s\S]*reviewed launch changeset/i + /launcher pull request[\s\S]*initial versioned release candidate/i + ) + assert.match( + versionPullRequests, + /no second initial Changesets version[\s\S]*pull request/i ) assert.match( versionPullRequests, @@ -3369,7 +3377,7 @@ test('documents stable and next channels with the exact eight-file artifact boun assert.match(semantics, /tag\s+must be the exact version prefixed with `v`/i) assert.match(semantics, /`latest` dist-tag/) assert.match(semantics, /`-next\.N` use `next`/i) - assert.match(semantics, /removes[\s\S]*accidental `latest`/i) + assert.match(semantics, /removes[\s\S]*unexpected `latest`/i) const artifactBoundary = extractMarkdownSection( releaseRunbook, diff --git a/scripts/release-artifacts.mjs b/scripts/release-artifacts.mjs index 37342fd8..e5aa29df 100644 --- a/scripts/release-artifacts.mjs +++ b/scripts/release-artifacts.mjs @@ -158,7 +158,9 @@ function parseManifest(bytes) { typeof manifest.version !== 'string' || !RELEASE_VERSION_PATTERN.test(manifest.version) ) { - throw new Error('manifest version must use MAJOR.MINOR.PATCH') + throw new Error( + 'manifest version must use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N' + ) } if (!Array.isArray(manifest.artifacts)) { throw new Error('manifest artifacts must be an array') @@ -173,7 +175,9 @@ function parseManifest(bytes) { export function expectedArtifacts(version) { if (typeof version !== 'string' || !RELEASE_VERSION_PATTERN.test(version)) { - throw new Error('release version must use MAJOR.MINOR.PATCH') + throw new Error( + 'release version must use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N' + ) } return PUBLIC_RELEASE_PACKAGES.map(config => ({ name: config.name, @@ -365,7 +369,9 @@ function repositoryReleaseVersion() { typeof version !== 'string' || !RELEASE_VERSION_PATTERN.test(version) ) { - throw new Error('public package versions must share one MAJOR.MINOR.PATCH') + throw new Error( + 'public package versions must share one MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-next.N' + ) } return version } @@ -863,12 +869,6 @@ export function verifyReleaseArtifacts({ artifactDirectory } = {}) { throw new Error(`unexpected artifact directory entry ${entry.name}`) } } - if (directoryEntries.length !== PUBLIC_RELEASE_PACKAGES.length + 2) { - throw new Error( - `artifact directory must contain exactly ${expectedEntries.size} entries` - ) - } - if (checksumBytes.toString('utf8') !== canonicalChecksums(artifacts)) { throw new Error( 'SHA256SUMS bytes do not match the required artifact order and format' diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs index 3d9ac64e..09fb49c9 100644 --- a/scripts/release-artifacts.test.mjs +++ b/scripts/release-artifacts.test.mjs @@ -505,8 +505,14 @@ test('derives the expected artifacts in dependency order', async () => { : `primitree-${name.slice('@primitree/'.length)}-1.0.0-next.0.tgz`, })) ) - assert.throws(() => expectedArtifacts('v1.0.0'), /MAJOR\.MINOR\.PATCH/) - assert.throws(() => expectedArtifacts('1.0.0-beta.1'), /MAJOR\.MINOR\.PATCH/) + assert.throws( + () => expectedArtifacts('v1.0.0'), + /or MAJOR\.MINOR\.PATCH-next\.N/ + ) + assert.throws( + () => expectedArtifacts('1.0.0-beta.1'), + /or MAJOR\.MINOR\.PATCH-next\.N/ + ) }) test('constructs stable public npm publish dry-run arguments', async () => { @@ -914,7 +920,7 @@ test('rejects malformed manifest metadata', async t => { f.manifest.version = 5 f.rewriteManifest() }, - /version.*MAJOR\.MINOR\.PATCH/i, + /version.*or MAJOR\.MINOR\.PATCH-next\.N/i, ], [ 'missing version value', @@ -922,7 +928,7 @@ test('rejects malformed manifest metadata', async t => { f.manifest.version = '' f.rewriteManifest() }, - /version.*MAJOR\.MINOR\.PATCH/i, + /version.*or MAJOR\.MINOR\.PATCH-next\.N/i, ], [ 'invalid version', @@ -930,7 +936,7 @@ test('rejects malformed manifest metadata', async t => { f.manifest.version = '5.0' f.rewriteManifest() }, - /version.*MAJOR\.MINOR\.PATCH/i, + /version.*or MAJOR\.MINOR\.PATCH-next\.N/i, ], [ 'unsupported prerelease version', @@ -938,7 +944,7 @@ test('rejects malformed manifest metadata', async t => { f.manifest.version = '1.0.0-beta.1' f.rewriteManifest() }, - /version.*MAJOR\.MINOR\.PATCH/i, + /version.*or MAJOR\.MINOR\.PATCH-next\.N/i, ], [ 'missing artifacts', diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index 2a2c2be0..f7b51ec0 100755 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -34,6 +34,14 @@ const PROVENANCE_PREDICATE = 'https://slsa.dev/provenance/v1' const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1' const REPOSITORY = 'https://github.com/marklearst/primitree' const WORKFLOW_PATH = '.github/workflows/ci.yml' +const LAUNCHER_RUNTIME_PACKAGES = PUBLIC_RELEASE_PACKAGES.filter(config => + [ + '@primitree/core', + '@primitree/dtcg', + '@primitree/cli', + 'primitree', + ].includes(config.name) +) function isPlainObject(value) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { @@ -649,7 +657,7 @@ export async function runReleasePublish({ for (const artifact of verified.artifacts) { let published = false - let accepted = false + let acceptedState for (let attempt = 1; attempt <= maxPollAttempts; attempt += 1) { const state = await inspectRegistryPackage({ artifact, @@ -662,7 +670,7 @@ export async function runReleasePublish({ version: verified.version, }) if (state.kind === 'present') { - accepted = true + acceptedState = state break } if (state.kind === 'missing' && published === false) { @@ -682,52 +690,34 @@ export async function runReleasePublish({ } if (attempt < maxPollAttempts) await sleep(retryDelayMs) } - if (!accepted) { + if (acceptedState === undefined) { throw new Error( `registry polling exhausted for ${artifact.name} after ${maxPollAttempts} attempts` ) } - } - if (isPrereleaseVersion(verified.version)) { - for (const artifact of verified.artifacts) { - const state = await inspectRegistryPackage({ - artifact, - commandContext, - commitSha: githubSha, - fetchJson, - registry, - runCommand, - tagRef: githubRef, - version: verified.version, - }) - if (state.kind !== 'present') { - throw new Error( - `prerelease dist-tag inspection failed for ${artifact.name}: ${state.kind}` - ) - } - if (state.metadata?.['dist-tags']?.latest === verified.version) { - requireCommandSuccess( - runCommand( - 'npm', - [ - 'dist-tag', - 'rm', - artifact.name, - 'latest', - `--registry=${registry}`, - '--fetch-retries=0', - `--fetch-timeout=${FETCH_TIMEOUT_MS}`, - ], - commandOptions(commandContext) - ), - `npm dist-tag rm ${artifact.name} latest` - ) - } + if ( + isPrereleaseVersion(verified.version) && + acceptedState.metadata?.['dist-tags']?.latest === verified.version + ) { + requireCommandSuccess( + runCommand( + 'npm', + [ + 'dist-tag', + 'rm', + artifact.name, + 'latest', + `--registry=${registry}`, + '--fetch-retries=0', + `--fetch-timeout=${FETCH_TIMEOUT_MS}`, + ], + commandOptions(commandContext) + ), + `npm dist-tag rm ${artifact.name} latest` + ) } - } - for (const artifact of verified.artifacts) { const state = await inspectRegistryPackage({ artifact, commandContext, @@ -791,6 +781,30 @@ function smokeCommand(runCommand, command, args, options) { ) } +function runExactLauncherSmoke({ consumerDirectory, options, runCommand }) { + smokeCommand( + runCommand, + 'node', + [ + path.join( + consumerDirectory, + 'node_modules', + 'primitree', + 'bin', + 'primitree.js' + ), + '--help', + ], + options + ) + smokeCommand( + runCommand, + path.join(consumerDirectory, 'node_modules', '.bin', 'primitree'), + ['--help'], + options + ) +} + function readInstalledPackageDocument( consumerDirectory, packageName, @@ -1345,6 +1359,7 @@ export function runPackedTarballConsumer({ commandOptions(commandContext, INSTALL_COMMAND_TIMEOUT_MS) ) assertInstalledVersions(consumerDirectory, verified.version) + runExactLauncherSmoke({ consumerDirectory, options, runCommand }) runInstalledPackageSmokeChecks({ consumerDirectory, options, @@ -1359,6 +1374,65 @@ export function runPackedTarballConsumer({ } } +function runPublicLauncherConsumer({ + version, + environment, + registry, + runCommand, +}) { + const commandContext = createNpmExecutionContext({ + environment, + keepOidc: false, + prefix: 'primitree-public-launcher-consumer-', + registry, + }) + const consumerDirectory = commandContext.workingDirectory + writeFileSync( + path.join(consumerDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'primitree-public-launcher-consumer', + version: '0.0.0', + private: true, + type: 'module', + }, + null, + 2 + )}\n` + ) + const options = commandOptions(commandContext) + + try { + assertNpmVersion(commandOutput('npm', ['--version'], options, runCommand)) + assertEffectiveRegistry(commandContext, registry, runCommand) + smokeCommand( + runCommand, + 'npm', + [ + 'install', + '--save-exact', + '--ignore-scripts', + '--engine-strict', + '--audit=false', + '--fund=false', + `--registry=${registry}`, + '--fetch-retries=0', + `--fetch-timeout=${FETCH_TIMEOUT_MS}`, + `primitree@${version}`, + ], + commandOptions(commandContext, INSTALL_COMMAND_TIMEOUT_MS) + ) + assertInstalledVersions( + consumerDirectory, + version, + LAUNCHER_RUNTIME_PACKAGES + ) + runExactLauncherSmoke({ consumerDirectory, options, runCommand }) + } finally { + commandContext.cleanup() + } +} + export async function runPublicRegistryConsumer({ version, environment = process.env, @@ -1366,6 +1440,7 @@ export async function runPublicRegistryConsumer({ runCommand = defaultRunCommand, }) { releaseChannelForVersion(version) + runPublicLauncherConsumer({ version, environment, registry, runCommand }) const commandContext = createNpmExecutionContext({ environment, keepOidc: false, diff --git a/scripts/release-publish.test.mjs b/scripts/release-publish.test.mjs index 1a37e4b4..5fae4f21 100644 --- a/scripts/release-publish.test.mjs +++ b/scripts/release-publish.test.mjs @@ -600,6 +600,83 @@ test('removes an accidental latest tag before accepting a first prerelease', asy } }) +test('cleans an accepted prerelease before a downstream publish failure', async () => { + const version = '1.0.0-next.0' + const fixture = fixtureArtifacts(version) + const core = fakePublishedPackage( + fixture.directory, + '@primitree/core', + version, + { latest: version, next: version } + ) + const calls = [] + + try { + await assert.rejects( + runReleasePublish({ + artifactDirectory: fixture.directory, + environment: { ...process.env, NPM_TOKEN: 'bootstrap-token' }, + githubRef: `refs/tags/v${version}`, + githubSha: SHA, + retryDelayMs: 0, + sleep: async () => {}, + fetchJson: async url => ({ + status: 200, + value: + url === core.metadata.dist.attestations.url + ? core.attestation + : undefined, + }), + runCommand(command, args, options = {}) { + calls.push([command, ...args]) + const configResult = registryConfigResult(args, options) + if (configResult !== undefined) return configResult + if (args[0] === '--version') { + return { status: 0, stdout: '11.18.0\n', stderr: '' } + } + if (args[0] === 'view') { + return args[1].startsWith('@primitree/core@') + ? { + status: 0, + stdout: JSON.stringify(core.metadata), + stderr: '', + } + : { status: 1, stdout: '', stderr: 'npm error code E404\n' } + } + if (args[0] === 'dist-tag' && args[1] === 'rm') { + delete core.metadata['dist-tags'].latest + return { status: 0, stdout: '', stderr: '' } + } + if (args[0] === 'publish') { + return { + status: 1, + stdout: '', + stderr: 'downstream publish failed', + } + } + throw new Error(`unexpected command ${command} ${args.join(' ')}`) + }, + }), + /npm publish @primitree\/dtcg@1\.0\.0-next\.0 failed/u + ) + + const cleanupIndex = calls.findIndex( + call => + call[1] === 'dist-tag' && + call[2] === 'rm' && + call[3] === '@primitree/core' + ) + const downstreamPublishIndex = calls.findIndex( + call => call[1] === 'publish' && call[2].includes('primitree-dtcg-') + ) + assert.notEqual(cleanupIndex, -1) + assert.ok(cleanupIndex < downstreamPublishIndex) + assert.deepEqual(core.metadata['dist-tags'], { next: version }) + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + test('validates one exact identity-aware SLSA provenance statement', () => { const fixture = fixtureArtifacts() try { @@ -1581,6 +1658,14 @@ test('smoke-tests downloaded tarballs without workspace dependencies', () => { calls.some(call => call[0].endsWith(`/node_modules/.bin/${bin}`)) ) } + assert.ok( + calls.some( + call => + call[0] === 'node' && + call[1].endsWith('/node_modules/primitree/bin/primitree.js') && + call[2] === '--help' + ) + ) const cliCalls = calls.filter(call => call[0].endsWith('/node_modules/.bin/primitree') ) @@ -1648,6 +1733,7 @@ test('smoke-tests downloaded tarballs without workspace dependencies', () => { 'brand', ], ['--help'], + ['--help'], ['check', '--format', 'json'], ] ) @@ -1752,8 +1838,19 @@ test('creates a hermetic public-registry consumer with exact installs and signat }, }) - const install = calls.find(call => call[0] === 'npm' && call[1] === 'install') + const installs = calls.filter( + call => call[0] === 'npm' && call[1] === 'install' + ) + assert.equal(installs.length, 2) + const [launcherInstall, install] = installs + assert.ok(launcherInstall) assert.ok(install) + assert.ok(launcherInstall.includes(`primitree@${VERSION}`)) + for (const config of PUBLIC_RELEASE_PACKAGES.filter( + config => config.name !== 'primitree' + )) { + assert.equal(launcherInstall.includes(`${config.name}@${VERSION}`), false) + } for (const config of PUBLIC_RELEASE_PACKAGES) { assert.ok(install.includes(`${config.name}@${VERSION}`)) } @@ -1829,6 +1926,26 @@ test('creates a hermetic public-registry consumer with exact installs and signat for (const bin of ['primitree', 'primitree-mcp']) { assert.ok(calls.some(call => call[0].endsWith(`/node_modules/.bin/${bin}`))) } + assert.ok( + calls.some( + call => + call[0] === 'node' && + call[1].endsWith('/node_modules/primitree/bin/primitree.js') && + call[2] === '--help' + ) + ) + const launcherSmokeIndex = calls.findIndex( + call => + call[0] === 'node' && + call[1].endsWith('/node_modules/primitree/bin/primitree.js') && + call[2] === '--help' + ) + const launcherBinIndex = calls.findIndex( + call => + call[0].endsWith('/node_modules/.bin/primitree') && call[1] === '--help' + ) + assert.ok(launcherSmokeIndex < installIndex) + assert.ok(launcherBinIndex < installIndex) assert.ok( calls .map((call, index) => ({ call, options: seenOptions[index] })) diff --git a/tests/e2e/docs-home-governance.spec.ts b/tests/e2e/docs-home-governance.spec.ts index f157013d..60d700a6 100644 --- a/tests/e2e/docs-home-governance.spec.ts +++ b/tests/e2e/docs-home-governance.spec.ts @@ -338,6 +338,10 @@ test('forbidden proposal reports the real policy result', async ({ page }) => { await expect(proposal).toHaveCount(1) await expect(proposal).toHaveAttribute('aria-pressed', 'false') await expect(status).toHaveText('PASS · 0 findings') + await expect(page.locator('.canopy-root')).toHaveAttribute( + 'data-stage', + 'ship' + ) await proposal.click() await expect(proposal).toHaveAttribute('aria-pressed', 'true') From 049502ce2104cd94d2738fbeb2891cf4855126ef Mon Sep 17 00:00:00 2001 From: Mark Learst Date: Sun, 16 Aug 2026 22:12:51 -0400 Subject: [PATCH 3/4] fix(release): clear stale prerelease latest tags --- docs/releasing.md | 12 ++- scripts/release-publish.mjs | 11 ++- scripts/release-publish.test.mjs | 145 +++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 6 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index f817b7e5..496626d4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -62,7 +62,8 @@ Packages publish in dependency order: core → dtcg → cli → hooks → mcp primitree. Stable versions use npm's `latest` dist-tag. Versions ending in `-next.N` use `next` and GitHub marks their release as a prerelease. The release controller also removes an unexpected `latest` tag from every prerelease -package and verifies that `next` identifies the exact version before +package, including when `latest` points to an older prerelease. It preserves a +stable `latest` and verifies that `next` identifies the exact version before continuing. ## Release artifact boundary @@ -1142,8 +1143,9 @@ npm publish "$ARTIFACT_DIR/primitree-$VERSION.tgz" --registry=https://registry.n ### Wrong dist-tag Inspect and repair a dist-tag without republishing. A prerelease must remain on -`next` and must not occupy `latest`. Remove `latest` when the listing shows that -it points to `$VERSION`; preserve it when it points to a stable version. +`next` and must not occupy `latest`. Remove `latest` whenever the listing shows +that it points to a prerelease, including an older prerelease; preserve it when +it points to a stable version. Use this core example for the package whose dist-tag needs repair: ```bash @@ -1152,7 +1154,9 @@ npm dist-tag add "@primitree/core@$VERSION" next --registry=https://registry.npm LATEST_VERSION="$( npm view "@primitree/core" dist-tags.latest --registry=https://registry.npmjs.org )" -if [ "$LATEST_VERSION" = "$VERSION" ]; then +if printf '%s\n' "$LATEST_VERSION" | + grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-' +then npm dist-tag rm "@primitree/core" latest --registry=https://registry.npmjs.org fi ``` diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index f7b51ec0..70c73e32 100755 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -196,6 +196,13 @@ function optionalRegistryObject(value, mismatchMessage) { return value } +function isPrereleaseDistTagValue(value) { + return ( + typeof value === 'string' && + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-/u.test(value) + ) +} + function validateRegistryMetadata({ artifactPath, metadata, expected }) { const label = `${expected.name}@${expected.version}` if (!isPlainObject(metadata)) { @@ -249,7 +256,7 @@ function validateRegistryMetadata({ artifactPath, metadata, expected }) { ) && complete if ( expected.forbidPrereleaseLatest === true && - distTags?.latest === expected.version + isPrereleaseDistTagValue(distTags?.latest) ) { throw new Error( `${label}: prerelease must not remain on the latest dist-tag` @@ -698,7 +705,7 @@ export async function runReleasePublish({ if ( isPrereleaseVersion(verified.version) && - acceptedState.metadata?.['dist-tags']?.latest === verified.version + isPrereleaseDistTagValue(acceptedState.metadata?.['dist-tags']?.latest) ) { requireCommandSuccess( runCommand( diff --git a/scripts/release-publish.test.mjs b/scripts/release-publish.test.mjs index 5fae4f21..de4875f7 100644 --- a/scripts/release-publish.test.mjs +++ b/scripts/release-publish.test.mjs @@ -600,6 +600,87 @@ test('removes an accidental latest tag before accepting a first prerelease', asy } }) +test('removes an older prerelease from latest before accepting a later prerelease', async () => { + const version = '1.0.0-next.1' + const previousVersion = '1.0.0-next.0' + const fixture = fixtureArtifacts(version) + const metadataByName = new Map() + const attestationByUrl = new Map() + const calls = [] + for (const config of PUBLIC_RELEASE_PACKAGES) { + const published = fakePublishedPackage( + fixture.directory, + config.name, + version, + { latest: previousVersion, next: version } + ) + metadataByName.set(config.name, published.metadata) + attestationByUrl.set( + published.metadata.dist.attestations.url, + published.attestation + ) + } + + try { + const result = await runReleasePublish({ + artifactDirectory: fixture.directory, + environment: { ...process.env, NPM_TOKEN: 'bootstrap-token' }, + githubRef: `refs/tags/v${version}`, + githubSha: SHA, + retryDelayMs: 0, + sleep: async () => {}, + fetchJson: async url => ({ + status: 200, + value: attestationByUrl.get(url), + }), + runCommand(command, args, options = {}) { + calls.push([command, ...args]) + const configResult = registryConfigResult(args, options) + if (configResult !== undefined) return configResult + if (args[0] === '--version') { + return { status: 0, stdout: '11.18.0\n', stderr: '' } + } + if (args[0] === 'view') { + const spec = args[1] + const name = spec.slice(0, spec.lastIndexOf('@')) + return { + status: 0, + stdout: JSON.stringify(metadataByName.get(name)), + stderr: '', + } + } + if (args[0] === 'dist-tag' && args[1] === 'rm') { + delete metadataByName.get(args[2])['dist-tags'].latest + return { status: 0, stdout: '', stderr: '' } + } + throw new Error(`unexpected command ${command} ${args.join(' ')}`) + }, + }) + + assert.equal(result.mode, 'bootstrap') + assert.deepEqual( + calls + .filter(call => call[1] === 'dist-tag') + .map(call => call.slice(1, 5)), + PUBLIC_RELEASE_PACKAGES.map(config => [ + 'dist-tag', + 'rm', + config.name, + 'latest', + ]) + ) + assert.equal( + calls.some(call => call[1] === 'publish'), + false + ) + for (const metadata of metadataByName.values()) { + assert.deepEqual(metadata['dist-tags'], { next: version }) + } + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + test('cleans an accepted prerelease before a downstream publish failure', async () => { const version = '1.0.0-next.0' const fixture = fixtureArtifacts(version) @@ -785,6 +866,70 @@ test('validates one exact identity-aware SLSA provenance statement', () => { } }) +test('preserves stable latest while validating a prerelease', () => { + const version = '1.0.0-next.1' + const fixture = fixtureArtifacts(version) + try { + const published = fakePublishedPackage( + fixture.directory, + '@primitree/core', + version, + { latest: '1.0.0', next: version } + ) + assert.doesNotThrow(() => + validatePublishedPackage({ + ...published, + expected: { + name: '@primitree/core', + version, + repository: REPOSITORY, + workflowPath: WORKFLOW_PATH, + tagRef: `refs/tags/v${version}`, + commitSha: SHA, + registry: PUBLIC_NPM_REGISTRY, + forbidPrereleaseLatest: true, + }, + }) + ) + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + +test('rejects any prerelease left on latest after cleanup', () => { + const version = '1.0.0-next.1' + const fixture = fixtureArtifacts(version) + try { + for (const latest of ['1.0.0-next.0', '2.0.0-beta.3']) { + const published = fakePublishedPackage( + fixture.directory, + '@primitree/core', + version, + { latest, next: version } + ) + assert.throws( + () => + validatePublishedPackage({ + ...published, + expected: { + name: '@primitree/core', + version, + repository: REPOSITORY, + workflowPath: WORKFLOW_PATH, + tagRef: `refs/tags/v${version}`, + commitSha: SHA, + registry: PUBLIC_NPM_REGISTRY, + forbidPrereleaseLatest: true, + }, + }), + /prerelease must not remain on the latest dist-tag/u + ) + } + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + test('publishes in dependency order, resumes partial publication, and polls delayed entries', async () => { const fixture = fixtureArtifacts() const calls = [] From 55a474e9378c3b7479cf0e72e7becd970a31cea5 Mon Sep 17 00:00:00 2001 From: Mark Learst Date: Sun, 16 Aug 2026 22:26:07 -0400 Subject: [PATCH 4/4] fix(release): allow later next candidates --- scripts/prerelease-contract.test.mjs | 39 +++++++++++++++++++--------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/scripts/prerelease-contract.test.mjs b/scripts/prerelease-contract.test.mjs index baded802..35e708f0 100644 --- a/scripts/prerelease-contract.test.mjs +++ b/scripts/prerelease-contract.test.mjs @@ -15,6 +15,14 @@ const packageNames = [ '@primitree/mcp', 'primitree', ] +const packageManifestPaths = [ + 'packages/core/package.json', + 'packages/dtcg/package.json', + 'packages/cli/package.json', + 'packages/hooks/package.json', + 'packages/mcp/package.json', + 'packages/primitree/package.json', +] function readJson(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, root), 'utf8')) @@ -32,7 +40,7 @@ function readLaunchChangeset() { ].map(match => ({ name: match[1], type: match[2] })) } -test('coordinates the first next prerelease through Changesets', () => { +test('coordinates the next prerelease train through Changesets', () => { const rootManifest = readJson('package.json') const changesets = readJson('.changeset/config.json') const prerelease = readJson('.changeset/pre.json') @@ -42,7 +50,15 @@ test('coordinates the first next prerelease through Changesets', () => { assert.deepEqual(changesets.fixed, [packageNames]) assert.equal(prerelease.mode, 'pre') assert.equal(prerelease.tag, 'next') - assert.deepEqual(prerelease.changesets, ['primitree-one']) + assert.ok(Array.isArray(prerelease.changesets)) + assert.ok(prerelease.changesets.includes('primitree-one')) + assert.equal( + new Set(prerelease.changesets).size, + prerelease.changesets.length + ) + assert.ok( + prerelease.changesets.every(id => typeof id === 'string' && id.length > 0) + ) assert.deepEqual( Object.keys(prerelease.initialVersions).sort(), [...packageNames].sort() @@ -52,16 +68,15 @@ test('coordinates the first next prerelease through Changesets', () => { version => version === '0.0.0' ) ) - for (const relativePath of [ - 'packages/core/package.json', - 'packages/dtcg/package.json', - 'packages/cli/package.json', - 'packages/hooks/package.json', - 'packages/mcp/package.json', - 'packages/primitree/package.json', - ]) { - assert.equal(readJson(relativePath).version, '1.0.0-next.0') - } + const packageVersions = packageManifestPaths.map( + relativePath => readJson(relativePath).version + ) + assert.equal(new Set(packageVersions).size, 1) + const [version] = packageVersions + assert.equal(typeof version, 'string') + assert.match(version, /^1\.0\.0-next\.(0|[1-9]\d*)$/u) + assert.equal(releaseChannelForVersion(version), 'next') + assert.equal(isPrereleaseVersion(version), true) assert.deepEqual( releases, packageNames.map(name => ({ name, type: 'major' }))