From e13a60369dab456b60efd9da9adb13312dfd6892 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 20 Mar 2026 15:10:46 +0800 Subject: [PATCH] feat: add package-first release flow --- .changeset/README.md | 38 +-- .changeset/config.json | 7 +- .changeset/quiet-points-admire.md | 6 + .github/workflows/changeset-status.yml | 3 + .github/workflows/publish-packages.yml | 66 +++++ .github/workflows/release-tag.yml | 55 ++++ .github/workflows/release-version-pr.yml | 4 +- README.md | 31 ++- docs/registry.md | 24 +- docs/releasing.md | 247 ++++++++--------- package.json | 5 +- packages/tokens/package.json | 16 +- packages/ui/package.json | 19 +- roadmap.md | 15 +- scripts/release-metadata.mjs | 99 +++++++ tests/package-consumer/smoke.mjs | 320 +++++++++++++++++++++++ 16 files changed, 764 insertions(+), 191 deletions(-) create mode 100644 .changeset/quiet-points-admire.md create mode 100644 .github/workflows/publish-packages.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 scripts/release-metadata.mjs create mode 100644 tests/package-consumer/smoke.mjs diff --git a/.changeset/README.md b/.changeset/README.md index 5ec9ffc..2fa9992 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -2,21 +2,28 @@ This directory is the release-intent ledger for Cadence UI. -The repo is still an internal/private monorepo, so Changesets is used conservatively: +Changesets now drives the package-first release flow for: -- track version intent for releasable workspace packages -- keep release notes attached to code changes -- avoid tagging private packages during early internal iteration -- let CI verify release intent before package work merges +- `@ai-ui/ui` +- `@ai-ui/tokens` + +The two packages move as a fixed version pair. Changesets remains the source of truth for: + +- version intent +- consumer-facing release notes +- version PR generation +- publish coordination for the package pair ## CI behavior -This repo now has two release-adjacent workflows: +This repo now has four release-adjacent workflows: - `Changeset Status` runs on pull requests and checks whether changes to releasable packages - include a `.changeset/*.md` entry, while also verifying that `registry/index.json` is current. + include a `.changeset/*.md` entry, while also verifying both package and registry consumer flows. - `Release Version PR` runs on `main` and opens or updates a version PR by running `pnpm release:version`. +- `Create Release Tag` runs on `main` after the version pair changes and creates `cadence-ui-vX.Y.Z`. +- `Publish Packages` runs on `cadence-ui-v*` tags and publishes the package pair. The PR check intentionally focuses on the releasable packages in scope today: @@ -62,14 +69,13 @@ Refactor dialog wrapper internals and update story coverage. ## Current release posture -The root repo is private and package publishing is not fully wired yet. Until the main release -flow is enabled, treat Changesets as the source of truth for: +The root repo remains private, but `@ai-ui/ui` and `@ai-ui/tokens` are now treated as published +packages. The automated flow is: -- which package versions should move -- which changes deserve release notes -- which internal dependency bumps should be coordinated +1. merge a changeset-backed package change +2. merge the version PR generated on `main` +3. let the tag and publish workflows ship the fixed package pair -The current automation stops at version intent, registry metadata refresh, and version PR creation. -It does not publish to npm, create tags, or assume any package registry credentials exist yet. - -See `docs/releasing.md` for the expected workflow around creating and consuming changesets. +See [docs/releasing.md](/Users/xd/project/cadence-ui/docs/releasing.md) for the current release +workflow and [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the optional +source-copy mode. diff --git a/.changeset/config.json b/.changeset/config.json index 2c42d07..7d26d85 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,7 +5,12 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "fixed": [], + "fixed": [ + [ + "@ai-ui/tokens", + "@ai-ui/ui" + ] + ], "linked": [], "ignore": [ "@ai-ui/docs" diff --git a/.changeset/quiet-points-admire.md b/.changeset/quiet-points-admire.md new file mode 100644 index 0000000..9b803a9 --- /dev/null +++ b/.changeset/quiet-points-admire.md @@ -0,0 +1,6 @@ +--- +"@ai-ui/tokens": minor +"@ai-ui/ui": minor +--- + +Ship package-first distribution for `@ai-ui/ui` and `@ai-ui/tokens`, including publishable package exports, package consumer validation, and automated tag-to-publish release flow. diff --git a/.github/workflows/changeset-status.yml b/.github/workflows/changeset-status.yml index 7af3d4d..1c093fb 100644 --- a/.github/workflows/changeset-status.yml +++ b/.github/workflows/changeset-status.yml @@ -105,3 +105,6 @@ jobs: - name: Run registry consumer smoke test run: pnpm test:registry:consumer + + - name: Run package consumer smoke test + run: pnpm test:package:consumer diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000..a9df001 --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,66 @@ +name: Publish Packages + +on: + push: + tags: + - "cadence-ui-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.25.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + scope: "@ai-ui" + + - name: Require npm token + shell: bash + run: | + set -euo pipefail + + if [[ -z "${NPM_TOKEN:-}" ]]; then + echo "::error::NPM_TOKEN secret is required to publish @ai-ui packages." + exit 1 + fi + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify tag matches package version + shell: bash + run: | + set -euo pipefail + expected_tag="$(node ./scripts/release-metadata.mjs --field tag)" + + if [[ "${expected_tag}" != "${GITHUB_REF_NAME}" ]]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match package version tag ${expected_tag}." + exit 1 + fi + + - name: Run release validation + run: pnpm release:validate + + - name: Publish packages + run: pnpm release:publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..f4daa03 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,55 @@ +name: Create Release Tag + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Detect release version change + id: release + shell: bash + run: | + set -euo pipefail + changed="$(node ./scripts/release-metadata.mjs --before "${{ github.event.before }}" --field changed)" + tag="$(node ./scripts/release-metadata.mjs --field tag)" + version="$(node ./scripts/release-metadata.mjs --field version)" + + echo "changed=${changed}" >> "$GITHUB_OUTPUT" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Create release tag + if: steps.release.outputs.changed == 'true' + shell: bash + run: | + set -euo pipefail + + if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Cadence UI ${VERSION}" + git push origin "${TAG}" + env: + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} diff --git a/.github/workflows/release-version-pr.yml b/.github/workflows/release-version-pr.yml index 5df0ae8..43c33af 100644 --- a/.github/workflows/release-version-pr.yml +++ b/.github/workflows/release-version-pr.yml @@ -37,8 +37,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run registry consumer smoke test - run: pnpm test:registry:consumer + - name: Run release validation + run: pnpm release:validate - name: Open or update version PR uses: changesets/action@v1 diff --git a/README.md b/README.md index ebb01ab..d7da59c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ default styling with its own tokens, motion recipes, and component contract. - The foundation, token layer, authoring contract, Storybook docs, and unit coverage are in place. - The public UI surface now includes the core form and overlay set plus advanced patterns such as `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`. -- The internal source-copy registry flow is live and validated with `pnpm registry:check` and `pnpm test:registry:consumer`. -- The main remaining release work is publish policy and tagging automation, not initial component bootstrapping. +- The default distribution path is package-first: `@ai-ui/ui` and `@ai-ui/tokens` are versioned and validated for package consumption. +- The internal source-copy registry flow remains available as an optional mode for teams that want local ownership of copied component source. ## System principles @@ -68,6 +68,7 @@ Run tests: ```bash pnpm test +pnpm test:package:consumer pnpm test:registry:consumer pnpm test:e2e:smoke ``` @@ -81,7 +82,13 @@ pnpm typecheck ## Package consumption -For package-style consumers, prefer a single CSS entrypoint from `@ai-ui/ui`: +Package consumption is the default path for downstream apps: + +```bash +pnpm add @ai-ui/ui +``` + +Prefer a single CSS entrypoint from `@ai-ui/ui`: ```tsx import { Button } from "@ai-ui/ui"; @@ -95,14 +102,19 @@ import { Button } from "@ai-ui/ui"; This keeps the app on one UI package import path while still pulling in token and skin styles together. Consumers that want lower-level control can still import -`@ai-ui/tokens/styles.css` and `@ai-ui/ui/skins.css` separately. +`@ai-ui/tokens/styles.css` and `@ai-ui/ui/skins.css` separately. If you need token helpers +such as `setTheme`, add `@ai-ui/tokens` directly as well. -Install source-owned components into another project: +If you need source ownership instead of package upgrades, use the optional registry +installer to copy component source into another project: ```bash pnpm registry:install --project ../acme-app button dialog ``` +Package release details live in [docs/releasing.md](/Users/xd/project/cadence-ui/docs/releasing.md). +Source-copy install and upgrade details live in [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md). + ## Workspace structure ```txt @@ -128,11 +140,11 @@ The system is layered: The current public component layer lives in `packages/ui/src/components`, with shared helpers in `packages/ui/src/lib`. -## Registry install flow +## Optional Source-Copy Flow -Cadence UI now ships a minimal internal registry flow for source-owned adoption. -Consumers pin this repo to a reviewed commit or tag, then run the local installer to -copy selected items into their own codebase. +Cadence UI still ships a registry installer for teams that want to copy component source +into their own app and keep editing it there. This is the advanced customization path, +not the default distribution path. - Registry metadata lives in `registry/index.json` and is generated by `pnpm registry:build`. - The generated index tracks transitive local helpers in addition to component entrypoints, so helper-import changes need a registry rebuild before merge. @@ -149,6 +161,7 @@ to document more than the default playground when behavior is non-trivial. The r uses: - Vitest + Testing Library for unit and interaction coverage +- package consumer smoke coverage for published-package consumption - Storybook interaction coverage for representative examples - Playwright smoke coverage for core Storybook flows - Storybook a11y checks as part of the docs review surface diff --git a/docs/registry.md b/docs/registry.md index 85a76c7..acc6c3a 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -1,8 +1,10 @@ # Registry Install and Upgrade -Cadence UI now supports a minimal internal registry flow for source-owned consumers. -The goal is not package publishing yet. The goal is to make one reviewed repo state -directly consumable by another app. +Cadence UI supports a source-copy registry flow for teams that want local ownership of +component source inside their own application. + +This is the optional customization path. The default downstream consumption model is now +package install via `@ai-ui/ui`, with `@ai-ui/tokens` available for lower-level token imports. ## What the registry contains @@ -52,12 +54,22 @@ required dependencies, and verifies that the copied source both typechecks and b ## Consumer install flow +Choose this flow when: + +- your team wants to edit copied component code locally +- you expect app-specific divergence that would be awkward to upstream immediately +- you prefer source ownership over centralized package upgrades + +If you just want to use Cadence UI in another app with the lowest maintenance overhead, +use the package flow in [docs/releasing.md](/Users/xd/project/cadence-ui/docs/releasing.md) +instead. + ### 1. Pin the Cadence UI source Consumers should install from a reviewed Cadence UI repo state, usually: -- the merge commit of a version PR, or -- a maintainer-created tag that points at that versioned commit +- the `cadence-ui-vX.Y.Z` tag created for the release, or +- the matching merge commit if the source-copy install must be tested before the tag is created The consumer does not need this repo as a runtime dependency. It only needs access to the checked-out source when running the installer. @@ -130,7 +142,5 @@ This registry flow is intentionally minimal: It does not yet: -- publish to npm - host a remote registry API -- auto-tag or auto-publish releases - resolve consumer-specific codemods during upgrade diff --git a/docs/releasing.md b/docs/releasing.md index 63f32d8..ee653e3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,47 +1,63 @@ # Releasing -This repo is not fully on a public-package release pipeline yet, but it is ready to use -Changesets as the canonical record of release intent. +Cadence UI now uses a package-first release model. -The current goal is modest: - -- version `@ai-ui/ui` and `@ai-ui/tokens` deliberately -- keep release notes attached to the changes that caused them -- avoid inventing ad hoc version bumps when the component system evolves -- make release intent enforceable in CI before package work merges -- keep the generated registry metadata aligned with versioned source - -## Current assumptions - -- The repository root is private. -- Workspace packages currently use explicit package versions even when they are not yet published. -- `@ai-ui/docs` is a consumer app, not a releasable package, so it is ignored by Changesets. -- Publishing mechanics and registry credentials are still to be added. - -Because of that, this baseline is intentionally conservative. - -## Packages in scope - -Changesets should currently be used for: +Default downstream consumers should install: - `@ai-ui/ui` -- `@ai-ui/tokens` -Package consumers should now prefer: +Add `@ai-ui/tokens` only when the consumer app imports token helpers or lower-level token CSS +directly. -- `@ai-ui/ui` for component imports -- `@ai-ui/ui/styles.css` for the combined token + skin stylesheet +The source-copy registry flow still exists, but it is the optional customization path, not the +default release target. See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) +for that mode. -The lower-level entries remain available when needed: +## Release artifacts -- `@ai-ui/tokens/styles.css` -- `@ai-ui/ui/skins.css` +Each release now has three artifacts: -Changes to the docs app alone usually do not need a changeset. +- published packages for `@ai-ui/ui` and `@ai-ui/tokens` +- a repository tag in the form `cadence-ui-vX.Y.Z` +- a committed `registry/index.json` snapshot for source-copy consumers pinned to that tag + +## Package policy + +- `@ai-ui/ui` and `@ai-ui/tokens` are released as a fixed version pair +- `@ai-ui/ui` is the primary component import surface +- `@ai-ui/ui/styles.css` is the default stylesheet entrypoint for package consumers +- `@ai-ui/tokens` remains available for lower-level token helpers and direct CSS imports +- `apps/docs` is a consumer app, not a releasable package + +The current workflows assume private scoped packages on the npm registry with `access: +restricted`. If this repo moves to another registry later, update the publish workflow and +consumer setup accordingly. + +## Consumer install + +Package consumers should install the packages directly: + +```bash +pnpm add @ai-ui/ui +``` + +Then import from `@ai-ui/ui` and point Tailwind at the packaged source: + +```tsx +import { Button } from "@ai-ui/ui"; +``` + +```css +@import "tailwindcss"; +@import "@ai-ui/ui/styles.css"; +@source "../node_modules/@ai-ui/ui/src"; +``` + +This flow is validated by `pnpm test:package:consumer`. ## When to create a changeset -Create a changeset when a merged change affects any consumer-facing surface of a releasable package: +Create a changeset when a merged change affects any consumer-facing package surface: - new components or slots - changed props or variants @@ -63,159 +79,104 @@ If CI would otherwise require a changeset for one of those exceptions, apply the Use semver pragmatically: -- `patch`: bug fixes, QA-only behavior fixes, docs fixes bundled with a small behavior correction +- `patch`: bug fixes, QA-only behavior fixes, or small consumer-visible corrections - `minor`: new components, new props, new variants, new tokens, additive API work -- `major`: breaking prop changes, renamed slots or states, removed variants, contract changes that require consumer updates +- `major`: breaking contract changes that require consumer updates -When in doubt, bias toward `minor` over underselling a visible new surface. +Because `@ai-ui/ui` and `@ai-ui/tokens` are fixed together, version bumps move as a pair. -## Recommended workflow +## Local maintainer commands -### 1. Make the code change - -Complete the implementation, docs, and tests first. - -At minimum, run: +Run the release validation suite locally with: ```bash -pnpm lint -pnpm registry:check -pnpm typecheck -pnpm test +pnpm release:validate ``` -Use the docs and smoke checks when the change touches behavior-heavy UI: +That command currently runs: -```bash -pnpm build:docs -pnpm test:registry:consumer -pnpm test:e2e:smoke -``` +- lint +- typecheck +- package unit tests +- package builds +- registry metadata validation +- source-copy consumer smoke +- package consumer smoke -### 2. Create a changeset - -After the change is ready, create a changeset entry for the affected package or packages. - -Once `@changesets/cli` is installed in the repo, the intended command is: +Create a versioned release change with: ```bash pnpm changeset ``` -The generated markdown file should: - -- select the impacted package(s) -- choose the correct version bump type -- include a short consumer-facing summary - -### 3. Review internal dependency impact - -This repo is configured to update internal dependencies with a patch bump. - -That means if `@ai-ui/tokens` changes and `@ai-ui/ui` depends on it, the versioning step should -keep the dependency graph coherent without requiring manual package edits. - -### 4. Version the packages - -When it is time to cut a release manually, run the repo version step: +Apply pending version bumps locally with: ```bash pnpm release:version ``` -That step is expected to: +Publish the packages manually, if needed, with: -- update package versions -- update internal dependency ranges where needed -- consume the pending changeset files -- rebuild `registry/index.json` so the copy-in install metadata stays version-aligned +```bash +pnpm release:publish +``` -Review the resulting package diffs carefully before merging. +## Automated release flow -On `main`, the repository also runs a `Release Version PR` workflow that opens or updates a -version PR with the same command. That workflow is intentionally limited to version-file updates; -it does not publish packages. +The automated release sequence is now: -### 5. Distribute the versioned source +1. A product or package PR merges to `main` with a changeset. +2. `Release Version PR` runs on `main`, executes `pnpm release:validate`, and opens or updates a version PR via Changesets. +3. A maintainer reviews and merges that version PR. +4. `Create Release Tag` detects the package version change on `main` and creates `cadence-ui-vX.Y.Z`. +5. `Publish Packages` runs on the tag push, validates the tagged commit again, and publishes `@ai-ui/ui` and `@ai-ui/tokens`. -Publishing is not fully wired in this repo yet, so the consumable artifact is the versioned -repository state itself. - -After the version PR merges: - -- keep the merge commit as a stable install point, or create a maintainer tag for it -- treat the committed `registry/index.json` as the install contract for that repo state -- point consumers at that commit or tag when they run `pnpm registry:install` - -See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the consumer -install and upgrade flow. +The tag is the stable source reference for source-copy consumers. The packages are the default +artifact for package consumers. ## CI workflows ### Pull request check -The `Changeset Status` workflow runs on pull requests and enforces a simple rule: +`Changeset Status` runs on pull requests and: -- if a PR changes `@ai-ui/ui` or `@ai-ui/tokens`, it should usually include a new - `.changeset/*.md` file -- if the work is intentionally non-releasable, maintainers can apply the - `no-changeset-needed` label - -When a changeset is present, the workflow also runs: - -```bash -pnpm changeset:status --since origin/main -``` - -This validates that pending changesets resolve cleanly against the base branch. - -The workflow also runs: - -```bash -pnpm registry:check -``` - -This prevents registry metadata drift from merging unnoticed. - -The repo also runs: - -```bash -pnpm test:registry:consumer -``` - -This verifies that the committed registry snapshot can still install into a fresh -consumer app, typecheck, and build. +- requires a changeset for releasable package changes unless `no-changeset-needed` is applied +- validates pending changesets +- checks that `registry/index.json` is current +- runs the source-copy consumer smoke test +- runs the package consumer smoke test ### Version PR workflow -The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It: +`Release Version PR` runs on pushes to `main` and: - installs dependencies -- runs `pnpm release:version` -- opens or updates a version PR using `changesets/action` +- runs `pnpm release:validate` +- opens or updates the version PR with `pnpm release:version` -This is still a versioning skeleton, not a publish pipeline, but the version PR now refreshes -the committed registry metadata that consumers install from. +### Tag workflow -## Future publish prerequisites +`Create Release Tag` runs on pushes to `main` and: -Before turning the version workflow into a publish workflow, the repo still needs: +- detects when the fixed package version pair changed +- creates `cadence-ui-vX.Y.Z` -- a root publish script, for example `pnpm changeset publish` or a guarded wrapper around it -- registry credentials such as `NPM_TOKEN` -- a decision on which workspace packages should actually be published versus versioned internally -- any extra validation that must run before publish is allowed +### Publish workflow + +`Publish Packages` runs on `cadence-ui-v*` tags and: + +- verifies the tag matches the package version +- runs `pnpm release:validate` +- publishes the packages with `NPM_TOKEN` + +## Required secrets + +The publish workflow currently requires: + +- `NPM_TOKEN`: token with publish access to the `@ai-ui` scope on the configured npm registry ## Notes for maintainers -- Keep `packages/ui/src/index.ts` and package exports aligned with any release-worthy surface. +- Keep `packages/ui/src/index.ts` and package exports aligned with the published surface. +- Keep `registry/index.json` in sync even though package publish is now the default; source-copy consumers still rely on it. - If a component lands without docs or tests, it should not move toward release yet. -- Prefer one clear changeset per consumer-visible change rather than bundling unrelated work. -- If a PR contains both infra and component work, separate the release notes so consumers can - understand what actually changed. - -## Main-thread follow-up still needed - -This baseline now covers changeset intent checks in PRs, registry metadata validation, and -version PR creation on `main`. What remains is the publish decision, tagging policy, and any -registry-specific automation beyond source-copy installs. diff --git a/package.json b/package.json index dea7e35..45b7f22 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,13 @@ "registry:build": "node ./scripts/build-registry.mjs", "registry:check": "node ./scripts/build-registry.mjs --check", "registry:install": "node ./scripts/registry-install.mjs", - "release:version": "pnpm changeset version && pnpm registry:build", + "release:publish": "pnpm build && pnpm changeset publish", + "release:validate": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm registry:check && pnpm test:registry:consumer && pnpm test:package:consumer", + "release:version": "pnpm changeset version && pnpm install --lockfile-only && pnpm registry:build", "test": "pnpm --filter @ai-ui/ui test", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts", + "test:package:consumer": "node ./tests/package-consumer/smoke.mjs", "test:registry:consumer": "node ./tests/registry/consumer-smoke.mjs", "test:watch": "pnpm --filter @ai-ui/ui test:watch", "typecheck": "pnpm -r typecheck" diff --git a/packages/tokens/package.json b/packages/tokens/package.json index a66df01..2c1e059 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -1,13 +1,20 @@ { "name": "@ai-ui/tokens", "version": "0.0.0", - "private": true, "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "sideEffects": [ "**/*.css" ], "exports": { - ".": "./src/index.ts", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + }, "./base.css": "./src/base.css", "./motion.css": "./src/motion.css", "./styles.css": "./src/styles.css", @@ -17,8 +24,11 @@ "dist", "src" ], + "publishConfig": { + "access": "restricted" + }, "scripts": { - "build": "tsup src/index.ts --clean --dts --format esm --out-dir dist", + "build": "tsup src/index.ts --clean --dts --format esm,cjs --out-dir dist", "typecheck": "tsc --noEmit -p tsconfig.json" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 60a290e..0b2af7b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,20 +1,33 @@ { "name": "@ai-ui/ui", "version": "0.0.0", - "private": true, "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "sideEffects": [ "**/*.css" ], "exports": { - ".": "./src/index.ts", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + }, "./styles.css": "./src/styles.css", "./skins.css": "./src/skins.css" }, "files": [ "dist", - "src" + "src", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + "!src/test" ], + "publishConfig": { + "access": "restricted" + }, "scripts": { "build": "tsup src/index.ts --clean --dts --format esm,cjs --out-dir dist", "test": "vitest run --config ../../vitest.config.ts", diff --git a/roadmap.md b/roadmap.md index 330b3e6..023f341 100644 --- a/roadmap.md +++ b/roadmap.md @@ -16,12 +16,12 @@ completed most of that baseline work: - phases 0 through 4 are effectively in place: workspace, tokens, authoring contract, core components, Storybook docs, and baseline tests - phase 5 has shipped its first advanced-pattern slice with `DataTable`, alongside `Command`, `Combobox`, `Sheet`, and `EmptyState` -- phase 6 has shipped its baseline source-copy registry flow, Changesets gating, and version PR automation +- phase 6 now covers package-first release automation, fixed-version package publishing, and the optional source-copy registry flow The next work is mostly hardening and distribution: - keep the registry metadata and consumer smoke flow reliable -- decide publish and tagging policy +- keep package publish validation and tag automation reliable - expand advanced patterns only where real product usage justifies them ## Product Principles @@ -368,7 +368,7 @@ Exit criteria: ## Phase 6: Internal Registry and Distribution Status: -Baseline source-copy distribution is shipped. Publish automation is still open. +Package-first publishing and optional source-copy distribution are both in place. Objective: Create an internal distribution model similar to `shadcn`, but based on our own rules. @@ -382,6 +382,9 @@ Deliverables: Delivered so far: +- fixed-version package release pair for `@ai-ui/ui` and `@ai-ui/tokens` +- package consumer smoke validation +- tag-driven publish workflow - generated `registry/index.json` - local `pnpm registry:install` copy-in workflow - `pnpm test:registry:consumer` validation @@ -389,13 +392,13 @@ Delivered so far: Remaining follow-up: -- publish decision and package registry posture -- tagging policy for stable install points +- package registry operational hardening - any automation beyond source-copy installs Exit criteria: -- consumers can pull components from our registry +- package consumers can install the published packages +- source-copy consumers can pull components from our registry - upgrades are documented and reproducible ## Testing Strategy diff --git a/scripts/release-metadata.mjs b/scripts/release-metadata.mjs new file mode 100644 index 0000000..35ef821 --- /dev/null +++ b/scripts/release-metadata.mjs @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const uiPackagePath = path.join(repoRoot, "packages", "ui", "package.json"); +const tokensPackagePath = path.join(repoRoot, "packages", "tokens", "package.json"); + +function parseArgs(argv) { + const options = { + before: null, + field: null + }; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + + if (current === "--before" || current === "--field") { + const next = argv[index + 1]; + + if (!next) { + throw new Error(`Expected a value after ${current}.`); + } + + if (current === "--before") { + options.before = next; + } else { + options.field = next; + } + + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${current}`); + } + + return options; +} + +function readVersion(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")).version; +} + +function readVersionAtRef(ref, filePath) { + if (!ref || /^0{40}$/.test(ref)) { + return null; + } + + try { + const content = execFileSync("git", ["show", `${ref}:${path.relative(repoRoot, filePath)}`], { + cwd: repoRoot, + encoding: "utf8" + }); + return JSON.parse(content).version; + } catch { + return null; + } +} + +function getReleaseMetadata(beforeRef) { + const uiVersion = readVersion(uiPackagePath); + const tokensVersion = readVersion(tokensPackagePath); + + if (uiVersion !== tokensVersion) { + throw new Error( + `Expected @ai-ui/ui and @ai-ui/tokens to share a version. Received ${uiVersion} and ${tokensVersion}.` + ); + } + + const previousUiVersion = readVersionAtRef(beforeRef, uiPackagePath); + const previousTokensVersion = readVersionAtRef(beforeRef, tokensPackagePath); + const changed = + previousUiVersion === null || + previousTokensVersion === null || + previousUiVersion !== uiVersion || + previousTokensVersion !== tokensVersion; + + return { + changed, + tag: `cadence-ui-v${uiVersion}`, + version: uiVersion + }; +} + +const options = parseArgs(process.argv.slice(2)); +const metadata = getReleaseMetadata(options.before); + +if (options.field) { + if (!(options.field in metadata)) { + throw new Error(`Unknown field "${options.field}".`); + } + + process.stdout.write(String(metadata[options.field])); +} else { + process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); +} diff --git a/tests/package-consumer/smoke.mjs b/tests/package-consumer/smoke.mjs new file mode 100644 index 0000000..06dff21 --- /dev/null +++ b/tests/package-consumer/smoke.mjs @@ -0,0 +1,320 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const packagesRoot = path.join(repoRoot, "packages"); +const tempPrefix = path.join(os.tmpdir(), "cadence-ui-package-consumer-"); +const keepArtifacts = process.env.CADENCE_KEEP_PACKAGE_SMOKE === "1"; +const skipBuild = process.env.CADENCE_SKIP_PACKAGE_BUILD === "1"; +const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +function toPosixPath(value) { + return value.split(path.sep).join(path.posix.sep); +} + +async function readJson(filePath) { + return JSON.parse(await fs.readFile(filePath, "utf8")); +} + +function formatCommand(command, args) { + return [command, ...args].join(" "); +} + +async function run(command, args, options = {}) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: { + ...process.env, + ...(options.env ?? {}) + }, + stdio: "inherit" + }); + + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error( + `${formatCommand(command, args)} failed with code ${code ?? "null"} and signal ${ + signal ?? "null" + }.` + ) + ); + }); + }); +} + +async function packPackage(packageDir, outputDir) { + const before = new Set(await fs.readdir(outputDir)); + + await run( + pnpmCommand, + ["pack", "--pack-destination", outputDir], + { + cwd: packageDir, + env: { + CI: "1" + } + } + ); + + const after = await fs.readdir(outputDir); + const created = after.filter((entry) => !before.has(entry) && entry.endsWith(".tgz")); + + if (created.length !== 1) { + throw new Error( + `Expected exactly one tarball from ${packageDir}, received ${created.length}.` + ); + } + + return path.join(outputDir, created[0]); +} + +function toFileDependency(projectDir, absolutePath) { + let relativePath = toPosixPath(path.relative(projectDir, absolutePath)); + + if (!relativePath.startsWith(".")) { + relativePath = `./${relativePath}`; + } + + return `file:${relativePath}`; +} + +async function writeConsumerFixture({ + projectDir, + tokensTarball, + uiTarball, + rootPackage, + uiPackage +}) { + const packageJson = { + name: "cadence-ui-package-consumer-smoke", + private: true, + type: "module", + dependencies: { + "@ai-ui/tokens": toFileDependency(projectDir, tokensTarball), + "@ai-ui/ui": toFileDependency(projectDir, uiTarball), + react: uiPackage.devDependencies.react, + "react-dom": uiPackage.devDependencies["react-dom"] + }, + devDependencies: { + "@tailwindcss/vite": rootPackage.devDependencies["@tailwindcss/vite"], + "@types/react": rootPackage.devDependencies["@types/react"], + "@types/react-dom": rootPackage.devDependencies["@types/react-dom"], + tailwindcss: rootPackage.devDependencies.tailwindcss, + typescript: rootPackage.devDependencies.typescript, + vite: rootPackage.devDependencies.vite + }, + pnpm: { + overrides: { + "@ai-ui/tokens": toFileDependency(projectDir, tokensTarball) + } + } + }; + + const tsconfig = { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + jsx: "react-jsx", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + types: ["react", "react-dom"] + }, + include: ["src", "vite.config.ts"] + }; + + const viteConfig = `import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [tailwindcss()] +}); +`; + + const indexHtml = ` + + + + + Cadence UI Package Smoke + + +
+ + + +`; + + const stylesSource = `@import "tailwindcss"; +@import "@ai-ui/ui/styles.css"; +@source "../node_modules/@ai-ui/ui/src"; +`; + + const mainSource = `import { createRoot } from "react-dom/client"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, + Input +} from "@ai-ui/ui"; +import { setTheme } from "@ai-ui/tokens"; + +import "./styles.css"; + +setTheme("light"); + +function App() { + return ( +
+
+
+

Cadence UI package smoke

+ +
+ + + + + + + Package consumer + + Verifies published package exports typecheck and build in a consumer app. + + + +
+
+ ); +} + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing root element."); +} + +createRoot(root).render(); +`; + + await fs.mkdir(path.join(projectDir, "src"), { recursive: true }); + await fs.writeFile(path.join(projectDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + await fs.writeFile(path.join(projectDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + await fs.writeFile(path.join(projectDir, "vite.config.ts"), viteConfig); + await fs.writeFile(path.join(projectDir, "index.html"), indexHtml); + await fs.writeFile(path.join(projectDir, "src", "global.d.ts"), 'declare module "*.css";\n'); + await fs.writeFile(path.join(projectDir, "src", "styles.css"), stylesSource); + await fs.writeFile(path.join(projectDir, "src", "main.tsx"), mainSource); +} + +async function main() { + const [rootPackage, uiPackage, tokensPackage] = await Promise.all([ + readJson(path.join(repoRoot, "package.json")), + readJson(path.join(packagesRoot, "ui", "package.json")), + readJson(path.join(packagesRoot, "tokens", "package.json")) + ]); + + if (uiPackage.version !== tokensPackage.version) { + throw new Error( + `Expected @ai-ui/ui and @ai-ui/tokens to share a version. Received ${uiPackage.version} and ${tokensPackage.version}.` + ); + } + + const projectDir = await fs.mkdtemp(tempPrefix); + const packDir = path.join(projectDir, "packed"); + let succeeded = false; + + const cleanup = async () => { + if (!succeeded || keepArtifacts) { + console.log(`Keeping package smoke fixture at ${projectDir}`); + return; + } + + await fs.rm(projectDir, { force: true, recursive: true }); + }; + + try { + if (!skipBuild) { + console.log("Building workspace packages for package smoke"); + await run(pnpmCommand, ["build"], { + cwd: repoRoot, + env: { + CI: "1" + } + }); + } + + await fs.mkdir(packDir, { recursive: true }); + + console.log("Packing @ai-ui/tokens"); + const tokensTarball = await packPackage(path.join(packagesRoot, "tokens"), packDir); + + console.log("Packing @ai-ui/ui"); + const uiTarball = await packPackage(path.join(packagesRoot, "ui"), packDir); + + console.log(`Creating package consumer fixture in ${projectDir}`); + await writeConsumerFixture({ + projectDir, + tokensTarball, + uiTarball, + rootPackage, + uiPackage + }); + + console.log("Installing package consumer dependencies"); + await run(pnpmCommand, ["install", "--ignore-workspace"], { + cwd: projectDir, + env: { + CI: "1" + } + }); + + console.log("Verifying CommonJS package entrypoints"); + await run(process.execPath, ["-e", 'const ui=require("@ai-ui/ui"); const tokens=require("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing CommonJS export.");}'], { + cwd: projectDir + }); + + console.log("Verifying ESM package entrypoints"); + await run(process.execPath, ["--input-type=module", "-e", 'const ui=await import("@ai-ui/ui"); const tokens=await import("@ai-ui/tokens"); if(!ui.Button||!tokens.setTheme){throw new Error("Missing ESM export.");}'], { + cwd: projectDir + }); + + console.log("Typechecking the package consumer fixture"); + await run(pnpmCommand, ["exec", "tsc", "-p", "tsconfig.json", "--noEmit"], { + cwd: projectDir + }); + + console.log("Building the package consumer fixture"); + await run(pnpmCommand, ["exec", "vite", "build"], { + cwd: projectDir, + env: { + CI: "1" + } + }); + + succeeded = true; + console.log("Package consumer smoke test passed."); + } catch (error) { + console.error(`Package consumer smoke test failed. Fixture preserved at ${projectDir}`); + throw error; + } finally { + await cleanup(); + } +} + +await main();