feat: add package-first release flow

This commit is contained in:
2026-03-20 15:10:46 +08:00
parent 3600f3fcd9
commit e13a60369d
16 changed files with 764 additions and 191 deletions
+22 -16
View File
@@ -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.
+6 -1
View File
@@ -5,7 +5,12 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"fixed": [],
"fixed": [
[
"@ai-ui/tokens",
"@ai-ui/ui"
]
],
"linked": [],
"ignore": [
"@ai-ui/docs"
+6
View File
@@ -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.
+3
View File
@@ -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
+66
View File
@@ -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 }}
+55
View File
@@ -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 }}
+2 -2
View File
@@ -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
+22 -9
View File
@@ -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
+17 -7
View File
@@ -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
+104 -143
View File
@@ -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.
+4 -1
View File
@@ -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"
+13 -3
View File
@@ -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"
}
}
+16 -3
View File
@@ -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",
+9 -6
View File
@@ -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
+99
View File
@@ -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`);
}
+320
View File
@@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cadence UI Package Smoke</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
`;
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 (
<main className="min-h-screen bg-[var(--color-background)] p-8 text-[var(--color-foreground)]">
<div className="mx-auto grid max-w-xl gap-6">
<div className="grid gap-3">
<h1 className="text-2xl font-semibold">Cadence UI package smoke</h1>
<Input defaultValue="team@cadence.dev" placeholder="Email" />
</div>
<Dialog>
<DialogTrigger asChild>
<Button>Open package smoke dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Package consumer</DialogTitle>
<DialogDescription>
Verifies published package exports typecheck and build in a consumer app.
</DialogDescription>
</DialogContent>
</Dialog>
</div>
</main>
);
}
const root = document.getElementById("root");
if (!root) {
throw new Error("Missing root element.");
}
createRoot(root).render(<App />);
`;
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();