From 5d13ab8cc2e4c738e102207d5d18b7e3097ca72a Mon Sep 17 00:00:00 2001 From: Mark Learst Date: Mon, 17 Aug 2026 10:41:12 -0400 Subject: [PATCH] fix(release): recover prerelease as next.1 --- .changeset/complete-next-one.md | 10 + .changeset/pre.json | 2 +- .github/workflows/ci.yml | 2 +- README.md | 2 +- apps/docs/content/docs/concepts/figma-mcp.mdx | 2 +- apps/docs/content/docs/mcp/index.mdx | 6 +- docs/launch/v1.0.0-next.0.md | 13 +- docs/launch/v1.0.0-next.1.md | 48 ++++ docs/releasing.md | 65 +++--- packages/cli/CHANGELOG.md | 14 ++ packages/cli/README.md | 2 +- packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 11 + packages/core/README.md | 2 +- packages/core/package.json | 2 +- packages/dtcg/CHANGELOG.md | 13 ++ packages/dtcg/README.md | 2 +- packages/dtcg/package.json | 2 +- packages/hooks/CHANGELOG.md | 14 ++ packages/hooks/README.md | 2 +- packages/hooks/package.json | 2 +- packages/mcp/CHANGELOG.md | 14 ++ packages/mcp/README.md | 4 +- packages/mcp/package.json | 2 +- packages/mcp/src/cli.ts | 2 +- packages/primitree/CHANGELOG.md | 13 ++ packages/primitree/README.md | 6 +- packages/primitree/package.json | 2 +- scripts/brand-rules.mjs | 1 + scripts/check-brand.test.mjs | 4 + scripts/check-release.test.mjs | 53 ++++- scripts/prerelease-contract.test.mjs | 4 + scripts/release-config.mjs | 8 +- scripts/release-publish.mjs | 108 ++++++--- scripts/release-publish.test.mjs | 216 +++++++++++++----- 35 files changed, 495 insertions(+), 160 deletions(-) create mode 100644 .changeset/complete-next-one.md create mode 100644 docs/launch/v1.0.0-next.1.md diff --git a/.changeset/complete-next-one.md b/.changeset/complete-next-one.md new file mode 100644 index 00000000..9d8fa4d7 --- /dev/null +++ b/.changeset/complete-next-one.md @@ -0,0 +1,10 @@ +--- +'@primitree/core': patch +'@primitree/dtcg': patch +'@primitree/cli': patch +'@primitree/hooks': patch +'@primitree/mcp': patch +'primitree': patch +--- + +Accept npm's prerelease bootstrap dist-tags and complete the package-family release after the `1.0.0-next.0` attempt stopped after publishing `@primitree/core`. diff --git a/.changeset/pre.json b/.changeset/pre.json index 6382683e..825ac06d 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -9,5 +9,5 @@ "@primitree/mcp": "0.0.0", "primitree": "0.0.0" }, - "changesets": ["primitree-one"] + "changesets": ["complete-next-one", "primitree-one"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 164dfe7a..80da5228 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: if: github.event.deleted != true && github.ref_type == 'tag' runs-on: ubuntu-latest needs: [quality, packed-consumer] - timeout-minutes: 30 + timeout-minutes: 45 environment: npm permissions: contents: read diff --git a/README.md b/README.md index 1e14d8a0..c7c8262e 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Access Token or a network request. Enterprise teams can use the live API hooks. "mcpServers": { "design-tokens": { "command": "npx", - "args": ["-y", "@primitree/mcp", "--tokens", "./variables.json"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./variables.json"] } } } diff --git a/apps/docs/content/docs/concepts/figma-mcp.mdx b/apps/docs/content/docs/concepts/figma-mcp.mdx index e4b75555..cc4ab0cb 100644 --- a/apps/docs/content/docs/concepts/figma-mcp.mdx +++ b/apps/docs/content/docs/concepts/figma-mcp.mdx @@ -58,7 +58,7 @@ Point the server at a raw local variables export or the output from `primitree b "mcpServers": { "design-tokens": { "command": "npx", - "args": ["-y", "@primitree/mcp", "--tokens", "./design-tokens"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./design-tokens"] } } } diff --git a/apps/docs/content/docs/mcp/index.mdx b/apps/docs/content/docs/mcp/index.mdx index f339567e..d0572d99 100644 --- a/apps/docs/content/docs/mcp/index.mdx +++ b/apps/docs/content/docs/mcp/index.mdx @@ -4,7 +4,7 @@ description: Serve repository design tokens to an MCP client. --- ```bash -npm i -D @primitree/mcp +npm i -D @primitree/mcp@next ``` `@primitree/mcp` requires Node.js 24 or newer. The server reads any variables JSON shape listed in [Export variables](/docs/getting-started/export-variables), or a built `design-tokens/` directory. @@ -16,7 +16,7 @@ npm i -D @primitree/mcp "mcpServers": { "design-tokens": { "command": "npx", - "args": ["-y", "@primitree/mcp", "--tokens", "./variables.json"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./variables.json"] } } } @@ -26,7 +26,7 @@ Or point at built output: ```json { - "args": ["-y", "@primitree/mcp", "--tokens", "./design-tokens"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./design-tokens"] } ``` diff --git a/docs/launch/v1.0.0-next.0.md b/docs/launch/v1.0.0-next.0.md index 40f6adc9..5706f421 100644 --- a/docs/launch/v1.0.0-next.0.md +++ b/docs/launch/v1.0.0-next.0.md @@ -1,6 +1,6 @@ # Primitree 1.0.0-next.0 -Status: Released 2026-08-17. +Status: Partial release 2026-08-17. `@primitree/core` reached npm; five packages did not. Primitree checks a local DTCG source against project layer and owner rules. The first public prerelease also reserves the unscoped `primitree` package name, so @@ -24,14 +24,15 @@ implementation or a separate configuration format. 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`. +- The reviewed artifacts prepared all six packages at `1.0.0-next.0`. The + attempt published `@primitree/core` and stopped before the other five. ## 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. +The package-family publication stopped after `@primitree/core` reached npm. +The other five packages and the GitHub Release were not published. npm assigned +both `next` and its required bootstrap `latest` to the core prerelease. The +reviewed `1.0.0-next.1` candidate supersedes this partial attempt. ## Requirements diff --git a/docs/launch/v1.0.0-next.1.md b/docs/launch/v1.0.0-next.1.md new file mode 100644 index 00000000..72ab0cd0 --- /dev/null +++ b/docs/launch/v1.0.0-next.1.md @@ -0,0 +1,48 @@ +# Primitree 1.0.0-next.1 + +Status: Released 2026-08-17. + +Primitree checks a local DTCG source against project layer and owner rules. This +candidate completes the package-family prerelease after the `1.0.0-next.0` +attempt stopped after publishing `@primitree/core`. + +```sh +npx primitree@next check +``` + +The unscoped 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.1`. + +## Prerelease channel + +Install this version through npm's `next` tag. npm may also assign `latest` to +a prerelease while a package has no stable version. `next` is the authoritative +prerelease channel, and stable `1.0.0` will establish the stable `latest` tag +after 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.1/README.md). diff --git a/docs/releasing.md b/docs/releasing.md index 1dce7233..c7176a02 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -95,10 +95,12 @@ 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. The release -controller also removes an unexpected `latest` tag from every prerelease -package, including when `latest` points to an older prerelease. It preserves a -stable `latest` and verifies that `next` identifies the exact version before -continuing. +controller verifies that `next` identifies the exact version before continuing. +npm may also assign a prerelease `latest` while creating a package without +stable history. The controller accepts that bootstrap state while the complete +published history contains no stable version. It rejects that state after a +stable release, preserves any stable `latest`, and does not mutate `latest` +during a prerelease. ## Release artifact boundary @@ -184,27 +186,31 @@ the previous phase. Run the complete local preflight, require the version pull request checks when one exists, and verify @primitree ownership, 2FA, and new-package rights. Confirm the protected npm and GitHub environments and rulesets are ready. Confirm the -repository has no remote tags or GitHub Releases: +candidate tag and GitHub Release do not exist. Prior immutable tags and releases +may remain: ```bash set -euo pipefail -REMOTE_TAG_REFS=$(git ls-remote --tags origin) -test -z "$REMOTE_TAG_REFS" -GITHUB_RELEASES=$( - gh release list --repo marklearst/primitree --json tagName +CANDIDATE_TAG_REFS=$( + git ls-remote --tags origin \ + "refs/tags/v$VERSION" \ + "refs/tags/v$VERSION^{}" ) -test "$GITHUB_RELEASES" = '[]' +test -z "$CANDIDATE_TAG_REFS" +CANDIDATE_RELEASE_COUNT=$( + gh release list \ + --repo marklearst/primitree \ + --limit 1000 \ + --json tagName \ + --jq "[.[] | select(.tagName == \"v$VERSION\")] | length" +) +test "$CANDIDATE_RELEASE_COUNT" = 0 ``` -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: +Verify that the source repository remains public. This lets package pages, +provenance, issue links, and source links resolve to the same public project: ```bash -gh repo edit marklearst/primitree \ - --visibility public \ - --accept-visibility-change-consequences test "$(gh repo view marklearst/primitree --json visibility --jq '.visibility')" = PUBLIC ``` @@ -1017,8 +1023,8 @@ 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 -eight files. Record the run ID, package pages, `next` dist-tags, absence of the -prerelease from `latest`, provenance, release notes, asset digests, and final +eight files. Record the run ID, package pages, exact `next` dist-tags, observed +`latest` values, provenance, release notes, asset digests, and final prerelease URL. ### 6. Configure all six trusted publishers @@ -1204,23 +1210,16 @@ 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` 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: +Inspect and repair the authoritative dist-tag without republishing. A +prerelease must remain on `next`. A package without a stable version may keep a +prerelease on `latest`; do not remove or move that bootstrap tag. Preserve a +stable `latest` after one exists. Use this core example for the package whose +`next` 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 -LATEST_VERSION="$( - npm view "@primitree/core" dist-tags.latest --registry=https://registry.npmjs.org -)" -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 +npm dist-tag ls "@primitree/core" --registry=https://registry.npmjs.org ``` ### Bad package contents @@ -1289,7 +1288,7 @@ run. Require GitHub to report a single `publish` job, a skipped or cancelled conclusion, and no steps: ```bash -VERSION=${VERSION:-1.0.0-next.0} +: "${VERSION:?Set VERSION to the wrong pushed tag version}" [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-next\.(0|[1-9][0-9]*)$ ]] OLD_RUN_ID=123456789 set -euo pipefail diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 8960b3fd..86da9677 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. +- Updated dependencies + - @primitree/core@1.0.0-next.1 + - @primitree/dtcg@1.0.0-next.1 + ## 1.0.0-next.0 (2026-08-17) +Release record: This version was not published. The package-family attempt +stopped after `@primitree/core` reached npm. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/cli/README.md b/packages/cli/README.md index 49954201..24d6f01d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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-next.0 changelog](CHANGELOG.md). +[1.0.0-next.1 changelog](CHANGELOG.md). ## License diff --git a/packages/cli/package.json b/packages/cli/package.json index 2084e4a1..938a3fc7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/cli", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "description": "Build, compare, check, scaffold, and export Figma Variables token projects.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index dad2c2d1..c624a9fc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,7 +1,18 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. + ## 1.0.0-next.0 (2026-08-17) +Release record: `@primitree/core` reached npm. The other five packages and the +GitHub Release were not published. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/core/README.md b/packages/core/README.md index ab446c16..ff8433d7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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-next.0 changelog](CHANGELOG.md). +[1.0.0-next.1 changelog](CHANGELOG.md). ## License diff --git a/packages/core/package.json b/packages/core/package.json index 44eadc4f..e27ff68a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/core", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "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 ee8e6c18..a7c31d15 100644 --- a/packages/dtcg/CHANGELOG.md +++ b/packages/dtcg/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. +- Updated dependencies + - @primitree/core@1.0.0-next.1 + ## 1.0.0-next.0 (2026-08-17) +Release record: This version was not published. The package-family attempt +stopped after `@primitree/core` reached npm. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/dtcg/README.md b/packages/dtcg/README.md index 917f6d09..486e7b3f 100644 --- a/packages/dtcg/README.md +++ b/packages/dtcg/README.md @@ -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-next.0 changelog](CHANGELOG.md). +[1.0.0-next.1 changelog](CHANGELOG.md). ## License diff --git a/packages/dtcg/package.json b/packages/dtcg/package.json index b32075c7..69dc489c 100644 --- a/packages/dtcg/package.json +++ b/packages/dtcg/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/dtcg", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "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 4716fb27..61e04c79 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. +- Updated dependencies + - @primitree/core@1.0.0-next.1 + - @primitree/dtcg@1.0.0-next.1 + ## 1.0.0-next.0 (2026-08-17) +Release record: This version was not published. The package-family attempt +stopped after `@primitree/core` reached npm. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 7ca33037..78c8f353 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -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-next.0 changelog](CHANGELOG.md). +[1.0.0-next.1 changelog](CHANGELOG.md). ## License diff --git a/packages/hooks/package.json b/packages/hooks/package.json index d0febf24..efc0e896 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/hooks", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "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 2af8f8cf..fff9f33a 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. +- Updated dependencies + - @primitree/core@1.0.0-next.1 + - @primitree/dtcg@1.0.0-next.1 + ## 1.0.0-next.0 (2026-08-17) +Release record: This version was not published. The package-family attempt +stopped after `@primitree/core` reached npm. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 4e4d5338..912f6c1e 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -14,7 +14,7 @@ Protocol. "mcpServers": { "design-tokens": { "command": "npx", - "args": ["-y", "@primitree/mcp", "--tokens", "./variables.json"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./variables.json"] } } } @@ -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-next.0 changelog](CHANGELOG.md). +[1.0.0-next.1 changelog](CHANGELOG.md). ## License diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f43921bc..5c5a10d9 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@primitree/mcp", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "description": "MCP server for listing, resolving, searching, and comparing Primitree design tokens.", "author": "Mark Learst", "license": "MIT", diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index 99e157da..edf12c86 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -18,7 +18,7 @@ Example MCP client config: "mcpServers": { "design-tokens": { "command": "npx", - "args": ["-y", "@primitree/mcp", "--tokens", "./variables.json"] + "args": ["-y", "@primitree/mcp@next", "--tokens", "./variables.json"] } } } diff --git a/packages/primitree/CHANGELOG.md b/packages/primitree/CHANGELOG.md index 4d4e9795..b52912eb 100644 --- a/packages/primitree/CHANGELOG.md +++ b/packages/primitree/CHANGELOG.md @@ -1,7 +1,20 @@ # Changelog +## 1.0.0-next.1 (2026-08-17) + +### Patch Changes + +- Accept npm's prerelease bootstrap dist-tags and complete the package-family + release after the `1.0.0-next.0` attempt stopped after publishing + `@primitree/core`. +- Updated dependencies + - @primitree/cli@1.0.0-next.1 + ## 1.0.0-next.0 (2026-08-17) +Release record: This version was not published. The package-family attempt +stopped after `@primitree/core` reached npm. + ### Major Changes - Launch the Primitree 1.0 package family. diff --git a/packages/primitree/README.md b/packages/primitree/README.md index b9ea7f9b..04590fb7 100644 --- a/packages/primitree/README.md +++ b/packages/primitree/README.md @@ -8,8 +8,10 @@ npx primitree@next --help npx primitree@next check ``` -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. +The `next` tag carries the 1.0 prerelease. npm may also expose a prerelease as +`latest` until the first stable version exists, so `next` is the authoritative +prerelease channel. 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/packages/primitree/package.json b/packages/primitree/package.json index d9c64577..c4b4e305 100644 --- a/packages/primitree/package.json +++ b/packages/primitree/package.json @@ -1,6 +1,6 @@ { "name": "primitree", - "version": "1.0.0-next.0", + "version": "1.0.0-next.1", "description": "Run the Primitree design token CLI from the unscoped package name.", "author": "Mark Learst", "license": "MIT", diff --git a/scripts/brand-rules.mjs b/scripts/brand-rules.mjs index ee7f91e5..6dd7a6a6 100644 --- a/scripts/brand-rules.mjs +++ b/scripts/brand-rules.mjs @@ -6,6 +6,7 @@ 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-next.1.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 8756d42d..d45f112a 100644 --- a/scripts/check-brand.test.mjs +++ b/scripts/check-brand.test.mjs @@ -213,6 +213,10 @@ test('permits only migration references in approved files', () => { path: 'docs/launch/v1.0.0-next.0.md', content: `The React package moved from ${hooksScope} to @primitree/hooks.`, }, + { + path: 'docs/launch/v1.0.0-next.1.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-release.test.mjs b/scripts/check-release.test.mjs index ea7b9f6d..07014b92 100644 --- a/scripts/check-release.test.mjs +++ b/scripts/check-release.test.mjs @@ -70,6 +70,10 @@ const nextReleaseNotes = readFileSync( new URL('../docs/launch/v1.0.0-next.0.md', import.meta.url), 'utf8' ) +const nextOneReleaseNotes = readFileSync( + new URL('../docs/launch/v1.0.0-next.1.md', import.meta.url), + 'utf8' +) const releaseRunbookUrl = new URL('../docs/releasing.md', import.meta.url) const releaseRunbook = existsSync(releaseRunbookUrl) ? readFileSync(releaseRunbookUrl, 'utf8') @@ -1331,7 +1335,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 6 public packages at 1\.0\.0-next\.0/ + /Release metadata valid for 6 public packages at 1\.0\.0-next\.1/ ) } finally { rmSync(cwd, { recursive: true, force: true }) @@ -1908,7 +1912,7 @@ test('publishes through one pinned dual-mode helper and then verifies the public publish, /if: github\.event\.deleted != true && github\.ref_type == 'tag'/ ) - assert.match(publish, /timeout-minutes: 30/) + assert.match(publish, /timeout-minutes: 45/) assert.match(publish, /node-version: 24\.18\.0/) assert.match(publish, /npm install --global npm@11\.18\.0/) assert.match(publish, /test "\$\(npm --version\)" = "11\.18\.0"/) @@ -2007,15 +2011,31 @@ test('keeps reviewed v1 release notes project-focused', () => { assert.doesNotMatch(v1ReleaseNotes, /—/) }) -test('documents the unscoped launcher and next channel in prerelease notes', () => { +test('documents the unscoped launcher and partial next zero release', () => { 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, /Status: Partial release 2026-08-17/) + assert.match( + nextReleaseNotes, + /`@primitree\/core` reached npm; five packages did not/ + ) + assert.doesNotMatch(nextReleaseNotes, /`latest` does not point/) assert.match(nextReleaseNotes, /blob\/v1\.0\.0-next\.0\/README\.md/) assert.doesNotMatch(nextReleaseNotes, /—/) }) +test('documents the complete next one package-family candidate', () => { + assert.match(nextOneReleaseNotes, /^# Primitree 1\.0\.0-next\.1$/m) + assert.match(nextOneReleaseNotes, /Status: Released 2026-08-17\./) + assert.match(nextOneReleaseNotes, /npx primitree@next check/) + assert.match(nextOneReleaseNotes, /forwards to `@primitree\/cli`/) + assert.match(nextOneReleaseNotes, /share version `1\.0\.0-next\.1`/) + assert.match(nextOneReleaseNotes, /`next` is the authoritative/) + assert.match(nextOneReleaseNotes, /blob\/v1\.0\.0-next\.1\/README\.md/) + assert.doesNotMatch(nextOneReleaseNotes, /—/) +}) + test('finalizes dated public release copy before tag validation and final binding', () => { const localPreflight = extractMarkdownSection( releaseRunbook, @@ -3509,7 +3529,11 @@ 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]*unexpected `latest`/i) + assert.match( + semantics, + /prerelease `latest`[\s\S]*published history contains no stable version/i + ) + assert.match(semantics, /does not mutate `latest`/i) const artifactBoundary = extractMarkdownSection( releaseRunbook, @@ -3556,11 +3580,15 @@ test('keeps dry-run and external npm and GitHub proof boundaries explicit', () = assert.doesNotMatch(external, /stale `v4\.2\.0` tag/i) assert.match( branchPreflight, - /REMOTE_TAG_REFS=\$\([\s\S]*git ls-remote --tags origin[\s\S]*\)[\s\S]*test -z "\$REMOTE_TAG_REFS"/ + /CANDIDATE_TAG_REFS=\$\([\s\S]*git ls-remote --tags origin[\s\S]*refs\/tags\/v\$VERSION[\s\S]*\)[\s\S]*test -z "\$CANDIDATE_TAG_REFS"/ ) assert.match( branchPreflight, - /GITHUB_RELEASES=\$\([\s\S]*gh release list --repo marklearst\/primitree --json tagName[\s\S]*\)[\s\S]*test "\$GITHUB_RELEASES" = '\[\]'/ + /CANDIDATE_RELEASE_COUNT=\$\([\s\S]*gh release list[\s\S]*--jq "\[\.\[\] \| select\(\.tagName == \\"v\$VERSION\\"\)\] \| length"[\s\S]*\)[\s\S]*test "\$CANDIDATE_RELEASE_COUNT" = 0/ + ) + assert.match( + branchPreflight, + /Prior immutable tags and releases[\s\S]*may remain/ ) assert.match( tagPhase, @@ -3679,7 +3707,6 @@ 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" 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', @@ -3690,6 +3717,11 @@ test('documents dist-tag, invalid-content, and immutable artifact recovery', () ]) { assert.ok(recovery.includes(command), `recovery must contain ${command}`) } + assert.doesNotMatch(recovery, /npm dist-tag rm .* latest/) + assert.match( + recovery, + /without a stable version[\s\S]*may keep a[\s\S]*prerelease on `latest`/i + ) assert.match(recovery, /bad package contents[\s\S]*new patch version/i) assert.match(recovery, /do not[\s\S]*(?:overwrite|unpublish)/i) assert.match(recovery, /run ID and commit SHA/i) @@ -3736,6 +3768,11 @@ test('preserves immutable tags and advances wrong-tag recovery to a new version' ) assert.match(wrongTag, /exact tag[\s\S]*exact head SHA/i) + assert.match( + wrongTag, + /: "\$\{VERSION:\?Set VERSION to the wrong pushed tag version\}"/ + ) + assert.doesNotMatch(wrongTag, /VERSION=\$\{VERSION:-/) assert.match( wrongTag, /jq -r '\.event'[\s\S]*= push[\s\S]*jq -r '\.headBranch'[\s\S]*"v\$VERSION"/ diff --git a/scripts/prerelease-contract.test.mjs b/scripts/prerelease-contract.test.mjs index 35e708f0..65498931 100644 --- a/scripts/prerelease-contract.test.mjs +++ b/scripts/prerelease-contract.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { isPrereleaseVersion, + isStableReleaseVersion, releaseChannelForVersion, } from './release-config.mjs' @@ -88,6 +89,9 @@ test('maps only stable and next versions to public release channels', () => { 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.equal(isStableReleaseVersion('1.0.0'), true) + assert.equal(isStableReleaseVersion('1.0.0-next.0'), false) + assert.equal(isStableReleaseVersion('1.0.0-beta.1'), false) assert.throws( () => releaseChannelForVersion('1.0.0-beta.1'), /release version/i diff --git a/scripts/release-config.mjs b/scripts/release-config.mjs index b683ad35..5dbb7ba6 100644 --- a/scripts/release-config.mjs +++ b/scripts/release-config.mjs @@ -67,13 +67,19 @@ const STABLE_RELEASE_VERSION_PATTERN = const NEXT_RELEASE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-next\.(0|[1-9]\d*)$/ +export function isStableReleaseVersion(version) { + return ( + typeof version === 'string' && STABLE_RELEASE_VERSION_PATTERN.test(version) + ) +} + 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 (isStableReleaseVersion(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' diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index 70c73e32..b82cbd41 100755 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -17,6 +17,7 @@ import { verifyReleaseArtifacts } from './release-artifacts.mjs' import { PUBLIC_RELEASE_PACKAGES, isPrereleaseVersion, + isStableReleaseVersion, releaseChannelForVersion, } from './release-config.mjs' @@ -28,7 +29,7 @@ const PUBLISH_COMMAND_TIMEOUT_MS = 5 * 60_000 const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000 const AUDIT_COMMAND_TIMEOUT_MS = 3 * 60_000 const FETCH_TIMEOUT_MS = 10_000 -const DEFAULT_POLL_ATTEMPTS = 10 +export const REGISTRY_POLL_ATTEMPTS = 120 const DEFAULT_POLL_DELAY_MS = 3_000 const PROVENANCE_PREDICATE = 'https://slsa.dev/provenance/v1' const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1' @@ -255,13 +256,19 @@ function validateRegistryMetadata({ artifactPath, metadata, expected }) { `${label}: ${releaseChannel} dist-tag mismatch` ) && complete if ( - expected.forbidPrereleaseLatest === true && + expected.hasPublishedStableVersion === true && isPrereleaseDistTagValue(distTags?.latest) ) { throw new Error( - `${label}: prerelease must not remain on the latest dist-tag` + `${label}: prerelease latest is invalid when a stable version exists` ) } + if ( + isPrereleaseDistTagValue(distTags?.latest) && + typeof expected.hasPublishedStableVersion !== 'boolean' + ) { + throw new Error(`${label}: published version history is required`) + } complete = validateAvailableField( dist?.integrity, @@ -513,6 +520,48 @@ function parseRegistryMetadata(name, version, result) { return { kind: 'present', metadata } } +function parsePublishedVersions(name, version, result) { + if (result.status !== 0) { + if (/^npm (?:error|ERR!) code E404\s*$/m.test(result.stderr ?? '')) { + return { kind: 'pending' } + } + throw new Error( + `${name}@${version}: npm version inventory request failed${ + result.stderr?.trim() ? `: ${result.stderr.trim()}` : '' + }` + ) + } + let versions + try { + versions = JSON.parse(result.stdout) + } catch { + throw new Error(`${name}@${version}: version inventory is not valid JSON`) + } + if ( + !Array.isArray(versions) || + versions.length === 0 || + versions.some(item => typeof item !== 'string') + ) { + throw new Error(`${name}@${version}: version inventory must be an array`) + } + for (const publishedVersion of versions) { + try { + releaseChannelForVersion(publishedVersion) + } catch { + throw new Error( + `${name}@${version}: version inventory contains an unsupported version` + ) + } + } + if (!versions.includes(version)) { + return { kind: 'pending' } + } + return { + kind: 'ready', + hasPublishedStableVersion: versions.some(isStableReleaseVersion), + } +} + async function inspectRegistryPackage({ artifact, commandContext, @@ -522,7 +571,6 @@ async function inspectRegistryPackage({ runCommand, tagRef, version, - forbidPrereleaseLatest = false, }) { const result = runCommand( 'npm', @@ -547,7 +595,32 @@ async function inspectRegistryPackage({ tagRef, commitSha, registry, - forbidPrereleaseLatest, + } + if ( + isPrereleaseVersion(version) && + isPrereleaseDistTagValue(state.metadata?.['dist-tags']?.latest) + ) { + const inventory = parsePublishedVersions( + artifact.name, + version, + runCommand( + 'npm', + [ + 'view', + artifact.name, + 'versions', + '--json', + `--registry=${registry}`, + '--fetch-retries=0', + `--fetch-timeout=${FETCH_TIMEOUT_MS}`, + ], + commandOptions(commandContext) + ) + ) + if (inventory.kind === 'pending') { + return { kind: 'pending', metadata: state.metadata } + } + expected.hasPublishedStableVersion = inventory.hasPublishedStableVersion } const metadataState = validateRegistryMetadata({ artifactPath: artifact.path, @@ -622,7 +695,7 @@ export async function runReleasePublish({ fetchJson = fetchAttestationJson, githubRef, githubSha, - maxPollAttempts = DEFAULT_POLL_ATTEMPTS, + maxPollAttempts = REGISTRY_POLL_ATTEMPTS, registry = PUBLIC_NPM_REGISTRY, retryDelayMs = DEFAULT_POLL_DELAY_MS, runCommand = defaultRunCommand, @@ -703,28 +776,6 @@ export async function runReleasePublish({ ) } - if ( - isPrereleaseVersion(verified.version) && - isPrereleaseDistTagValue(acceptedState.metadata?.['dist-tags']?.latest) - ) { - 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` - ) - } - const state = await inspectRegistryPackage({ artifact, commandContext, @@ -734,7 +785,6 @@ export async function runReleasePublish({ runCommand, tagRef: githubRef, version: verified.version, - forbidPrereleaseLatest: isPrereleaseVersion(verified.version), }) if (state.kind !== 'present') { throw new Error( diff --git a/scripts/release-publish.test.mjs b/scripts/release-publish.test.mjs index de4875f7..9b2b4640 100644 --- a/scripts/release-publish.test.mjs +++ b/scripts/release-publish.test.mjs @@ -13,6 +13,7 @@ import path from 'node:path' import test from 'node:test' import { PUBLIC_NPM_REGISTRY, + REGISTRY_POLL_ATTEMPTS, REQUIRED_NPM_VERSION, assertInstalledPackageDocumentation, assertNpmVersion, @@ -133,12 +134,14 @@ function metadataFor( bytes, registry = PUBLIC_NPM_REGISTRY, version = VERSION, - distTags = { latest: version } + distTags = { latest: version }, + versions = [version] ) { const encodedName = name.replace('/', '%2f') return { name, version, + versions, 'dist-tags': distTags, dist: { integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, @@ -154,7 +157,8 @@ function fakePublishedPackage( directory, name, version = VERSION, - distTags = { latest: version } + distTags = { latest: version }, + versions = [version] ) { const file = artifactFile(name, version) const artifactPath = path.join(directory, file) @@ -162,7 +166,14 @@ function fakePublishedPackage( return { artifactPath, attestation: attestationFor(name, bytes, {}, version), - metadata: metadataFor(name, bytes, PUBLIC_NPM_REGISTRY, version, distTags), + metadata: metadataFor( + name, + bytes, + PUBLIC_NPM_REGISTRY, + version, + distTags, + versions + ), } } @@ -375,6 +386,11 @@ test('requires the exact npm release version', () => { assert.throws(() => assertNpmVersion('11.18.1\n'), /npm 11\.18\.0/) }) +test('keeps registry propagation polling bounded beyond five minutes', () => { + assert.ok(REGISTRY_POLL_ATTEMPTS * 3_000 >= 5 * 60_000) + assert.ok(REGISTRY_POLL_ATTEMPTS * 3_000 <= 6 * 60_000) +}) + test('requires documentation for every installed public package', t => { const consumerDirectory = mkdtempSync( path.join(tmpdir(), 'primitree-package-docs-') @@ -520,7 +536,7 @@ test('uses the npm channel selected by the exact release version', () => { ) }) -test('removes an accidental latest tag before accepting a first prerelease', async () => { +test('accepts npm bootstrap latest when no stable version exists', async () => { const version = '1.0.0-next.0' const fixture = fixtureArtifacts(version) const metadataByName = new Map() @@ -559,6 +575,13 @@ test('removes an accidental latest tag before accepting a first prerelease', asy if (args[0] === '--version') { return { status: 0, stdout: '11.18.0\n', stderr: '' } } + if (args[0] === 'view' && args[2] === 'versions') { + return { + status: 0, + stdout: JSON.stringify([version]), + stderr: '', + } + } if (args[0] === 'view') { const spec = args[1] const name = spec.slice(0, spec.lastIndexOf('@')) @@ -568,39 +591,31 @@ test('removes an accidental latest tag before accepting a first prerelease', asy 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] === 'dist-tag'), + false ) assert.equal( calls.some(call => call[1] === 'publish'), false ) for (const metadata of metadataByName.values()) { - assert.deepEqual(metadata['dist-tags'], { next: version }) + assert.deepEqual(metadata['dist-tags'], { + latest: version, + next: version, + }) } } finally { rmSync(fixture.directory, { recursive: true, force: true }) } }) -test('removes an older prerelease from latest before accepting a later prerelease', async () => { +test('accepts an older bootstrap latest while advancing next', async () => { const version = '1.0.0-next.1' const previousVersion = '1.0.0-next.0' const fixture = fixtureArtifacts(version) @@ -612,7 +627,8 @@ test('removes an older prerelease from latest before accepting a later prereleas fixture.directory, config.name, version, - { latest: previousVersion, next: version } + { latest: previousVersion, next: version }, + [previousVersion, version] ) metadataByName.set(config.name, published.metadata) attestationByUrl.set( @@ -640,6 +656,13 @@ test('removes an older prerelease from latest before accepting a later prereleas if (args[0] === '--version') { return { status: 0, stdout: '11.18.0\n', stderr: '' } } + if (args[0] === 'view' && args[2] === 'versions') { + return { + status: 0, + stdout: JSON.stringify([previousVersion, version]), + stderr: '', + } + } if (args[0] === 'view') { const spec = args[1] const name = spec.slice(0, spec.lastIndexOf('@')) @@ -649,46 +672,39 @@ test('removes an older prerelease from latest before accepting a later prereleas 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] === 'dist-tag'), + false ) assert.equal( calls.some(call => call[1] === 'publish'), false ) for (const metadata of metadataByName.values()) { - assert.deepEqual(metadata['dist-tags'], { next: version }) + assert.deepEqual(metadata['dist-tags'], { + latest: previousVersion, + next: version, + }) } } finally { rmSync(fixture.directory, { recursive: true, force: true }) } }) -test('cleans an accepted prerelease before a downstream publish failure', async () => { +test('rejects prerelease latest when a stable version exists', async () => { const version = '1.0.0-next.0' const fixture = fixtureArtifacts(version) const core = fakePublishedPackage( fixture.directory, '@primitree/core', version, - { latest: version, next: version } + { latest: version, next: version }, + ['1.0.0', version] ) const calls = [] @@ -715,6 +731,13 @@ test('cleans an accepted prerelease before a downstream publish failure', async if (args[0] === '--version') { return { status: 0, stdout: '11.18.0\n', stderr: '' } } + if (args[0] === 'view' && args[2] === 'versions') { + return { + status: 0, + stdout: JSON.stringify(['1.0.0', version]), + stderr: '', + } + } if (args[0] === 'view') { return args[1].startsWith('@primitree/core@') ? { @@ -724,35 +747,76 @@ test('cleans an accepted prerelease before a downstream publish failure', async } : { 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: '' } + throw new Error(`unexpected command ${command} ${args.join(' ')}`) + }, + }), + /stable version exists/u + ) + assert.equal( + calls.some(call => call[1] === 'dist-tag'), + false + ) + assert.equal( + calls.some(call => call[1] === 'publish'), + false + ) + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + +test('fails closed on malformed prerelease version inventory', async () => { + const version = '1.0.0-next.1' + const fixture = fixtureArtifacts(version) + const core = fakePublishedPackage( + fixture.directory, + '@primitree/core', + version, + { latest: '1.0.0-next.0', next: version }, + ['1.0.0-next.0', 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 () => ({ status: 200, value: core.attestation }), + 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' && args[2] === 'versions') { + return { status: 0, stdout: '{}', stderr: '' } } - if (args[0] === 'publish') { + if (args[0] === 'view') { return { - status: 1, - stdout: '', - stderr: 'downstream publish failed', + status: 0, + stdout: JSON.stringify(core.metadata), + stderr: '', } } throw new Error(`unexpected command ${command} ${args.join(' ')}`) }, }), - /npm publish @primitree\/dtcg@1\.0\.0-next\.0 failed/u + /version inventory must be an array/u ) - - const cleanupIndex = calls.findIndex( - call => - call[1] === 'dist-tag' && - call[2] === 'rm' && - call[3] === '@primitree/core' + assert.equal( + calls.some(call => call[1] === 'publish'), + false ) - const downstreamPublishIndex = calls.findIndex( - call => call[1] === 'publish' && call[2].includes('primitree-dtcg-') + assert.equal( + calls.some(call => call[1] === 'dist-tag'), + false ) - assert.notEqual(cleanupIndex, -1) - assert.ok(cleanupIndex < downstreamPublishIndex) - assert.deepEqual(core.metadata['dist-tags'], { next: version }) } finally { rmSync(fixture.directory, { recursive: true, force: true }) } @@ -887,7 +951,37 @@ test('preserves stable latest while validating a prerelease', () => { tagRef: `refs/tags/v${version}`, commitSha: SHA, registry: PUBLIC_NPM_REGISTRY, - forbidPrereleaseLatest: true, + hasPublishedStableVersion: true, + }, + }) + ) + } finally { + rmSync(fixture.directory, { recursive: true, force: true }) + } +}) + +test('allows bootstrap latest without a stable published version', () => { + 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.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, + hasPublishedStableVersion: false, }, }) ) @@ -896,7 +990,7 @@ test('preserves stable latest while validating a prerelease', () => { } }) -test('rejects any prerelease left on latest after cleanup', () => { +test('rejects prerelease latest when stable history exists', () => { const version = '1.0.0-next.1' const fixture = fixtureArtifacts(version) try { @@ -919,10 +1013,10 @@ test('rejects any prerelease left on latest after cleanup', () => { tagRef: `refs/tags/v${version}`, commitSha: SHA, registry: PUBLIC_NPM_REGISTRY, - forbidPrereleaseLatest: true, + hasPublishedStableVersion: true, }, }), - /prerelease must not remain on the latest dist-tag/u + /stable version exists/u ) } } finally {