Repository navigation
Release Process
Status: Active and authoritative for public releases. This page governs public Astryx release cadence, release gates, version selection, publishing, and safety. Internal operator details—hosts, credentials, internal trackers, internal announcements, and automation ownership—live in private instructions and cannot override this public policy. All stable packages publish to public npm under
@astryxdesign/*throughrelease.ymlwith trusted publishing (OIDC); there is noNPM_TOKEN. Read npmlatestand the newest GitHub Release for the current published version rather than copying a version number into this page.
-
Scheduled authority: Astryx has one scheduled release attempt each day, including weekends, through the normal release automation at 8:00 AM Pacific Time. This scheduled tick is the only automatic authority to begin a release. Before it creates a branch, it dispatches the canonical full-scope
release-checkon an exact commit from currentmainand waits for visual, accessibility, and RTL success. The cut SHA must equal that green receipt's head and remain in current remotemainhistory. Later fast-forward movement does not invalidate the receipt; a rewrite or divergence that removes the checked commit does. - Human-activated deviations: every additional or off-schedule attempt requires explicit human activation immediately before execution. This includes hotfixes, retries, recuts, reruns, resuming a failed or partial cut, and manual tag, npm, GitHub Release, deployment, or bookkeeping actions. Activation names one attempt and is consumed by that attempt. Standing authorization, prior approval, an automated inference, a severity label, or a prepared branch or PR is not activation. If schedule or authority is ambiguous, do not release.
- Failed scheduled attempt: do not retry or resume automatically. A specifically activated retry may run from 11:00 AM through 4:59 PM Pacific Time that day. Never begin a cut at or after 5:00 PM. At the daily cutoff, an unresolved attempt with proven absence of tag, exact npm version, GitHub Release, stable dispatch, production deployment, and bookkeeping sync is cancelled, recorded as terminally aborted, and cleaned up through the supported closure flow. The next day's scheduled tick may start one fresh attempt for the same unpublished version from a new exact-main cut; it never reopens or reuses the aborted attempt. A partial or uncertain publication state remains protected and requires an explicit human decision.
- Impact classification: before using any urgency, hotfix, or release path, classify the affected surface from the code and package graph: shipped npm package, public docsite, internal Storybook, sandbox, or tests/tooling. Release or hotfix evaluation begins only for actual shipped or public user impact. Internal Storybook, sandbox, and tests/tooling-only changes are routine maintenance: no Changeset, release note, urgency, or release.
- Cancellation and steering: new owner direction applies to the active release work immediately. A cancellation or incompatible objective first disarms every pending merge, tag, publish, deploy, and sync action; verification and cleanup come after containment.
- Safety: skipping a release is better than publishing broken or incompletely described bytes. A hold must identify the concrete failed check or unresolved public contract.
-
Version selection: pending Changesets determine the version. While packages are
0.x,[breaking]entries produce a minor release;[experimental]entries and every other published category produce a patch. An incompatible change confined to an explicitly experimental surface stays patch-level. Never downgrade or omit a valid stable breaking entry to force a patch. -
Minor-release frequency: while packages are
0.x, publish at most one minor release in any rolling seven-day period. Minor releases have no weekday restriction. If a pending[breaking]Changeset would violate that interval, roll the complete scope forward until the interval opens; do not selectively consume other Changesets around it. - Minor-release communication: every minor release requires a public docsite blog post covering the complete change range since the previous minor, including the upgrade command and all breaking migrations. Prepare and validate the post before the cut, but merge it only after npm and the GitHub Release exist. The minor-release process is not complete—and its release tracker must remain open—until the deployed blog URL returns successfully.
-
Stable scope: packages marked
astryx.canaryOnly: trueare outside the stable cut. Their findings remain quality work but do not block stable npm publishing. -
Release gate: every pending stable Changeset must resolve to its introducing PR, and that exact final PR head must have complete
pr-a11yandpr-visualCI evidence. The detailed contract is in Step 1. -
Branch cut and attempt identity: after the canonical check is green, cut exactly one active
release/vX.Y.Zbranch from that exact checked SHA while it remains in currentmainhistory. The checked SHA need not still be the tip; later fast-forward commits are next-release input. Commit.release/active.jsonand.release/plan.jsonon that branch with the version, branch, cut SHA, frozen Changeset paths and content hashes, active state, and canonical plan digest. That active attempt and its explicit release tracker are the release authority until closure. An attempt is distinct from its semantic version: one active attempt owns one branch, head, plan, and receipt at a time. Never reopen, retarget, rename, or roll a terminal attempt forward. A retry before closure stays on that attempt's branch and plan unless an explicitly authorized same-release revision updates the plan digest. A completely aborted, never-published attempt may be followed by either the next daily scheduled attempt or an explicitly human-activated same-day attempt at the same version, but only after durable proof that the tag, exact npm version, GitHub Release, stable dispatch, production deployment, bookkeeping sync, branch, worktree, and active marker are all absent. The new attempt recreates the conventional branch name from a fresh checkedmaincommit with a new attempt ID, head, plan digest, and timestamps; the prior terminal receipt remains immutable. Any published or partially published effect permanently blocks version reuse. -
Inclusion boundary: Changesets and code present at the cut SHA belong to this release. Later
mainchanges belong to the next release and neither retrigger nor invalidate the active release. Add a post-cut change only through an explicit owner-authorized cherry-pick onto the release branch; every cherry-pick changes the authoritative branch head, explicitly revises the plan digest when release inputs change, and reruns the required branch gates. -
Release identity: the version PR targets the active release branch and consumes exactly its frozen Changesets. One exact merged version-bump commit owns the lockfile, changelogs, release notes, package versions, manifests/codemods, immutable
vX.Y.Ztag, release-check receipts, dry run, publish run, GitHub Release, and deployment verification. Never regenerate from movingmain, and never publish stable frommain. -
CI authority: the canonical
release-checkhas two read-only modes. Before a cut, exact-main mode accepts onlymainwhen the dispatch ref, checkout SHA, and requested head agree and that exact checked SHA remains an ancestor of current remotemainat startup and completion. Normal fast-forward movement does not invalidate the receipt. After the generated bump merges, release-branch mode accepts only the single marked active release branch and exact matching branch name, version, head SHA, marker, and plan digest. Stable publication still rejectsmain, an unmarked or wrong branch, a stale head, a wrong version/tag/package/plan, or ambiguous active branches. Stable builds fresh from the tag and cannot consume canary artifacts.maincontinues independent canary publication from its current package version; canaries neither mutate nor authorize the active stable release. Ordinary PR CI remains unchanged. -
Branch protection and closure: while active, the release branch and its worktree are protected from force-push, deletion, age/capacity cleanup, and generic cleanup. Missing or uncertain marker/tracker state fails closed. Never prune an active, failed, partially published, rollback-investigation, or unresolved release branch before terminal handling. A cancellation first enters
cancelling, disarms every pending merge, tag, publish, deploy, and sync action, and records anabortedterminal receipt before audit or cleanup. An aborted, never-published branch may be pruned only after durable proof that no tag, exact npm version, GitHub Release, stable dispatch, production deployment, or bookkeeping sync exists. That terminal receipt is immutable. The next daily scheduled tick may start a fresh same-version attempt after complete cleanup; any same-day or off-schedule restart still requires explicit human activation. Every restart also requires live proof that the prior branch, worktree, and active marker were cleaned up, and repeated aborts each retain separate attempt IDs and receipts. A completed release requires verified npm packages, immutable tag, GitHub Release, deployed public package/docsite surfaces, detached-tag published-byte parity, generated consumed-Changeset sync merged tomain, durable release metadata and verification record, and a closed release tracker. Only a complete released or aborted terminal receipt permits removing cleanup protection and deleting the remote/local branch through the explicit supported close flow. Every release branch is eventually pruned; none is preserved as a substitute for missing terminal evidence. - Patch communication: the npm publication, immutable tag, and GitHub Release are the durable public record. Internal announcement mechanics are operational details, not public release policy.
Astryx releases all packages at the same version number for clear compatibility. The release process:
-
Prove and cut the release head — run the canonical full-scope check on an exact commit from current
main, then create the single markedrelease/vX.Y.Zbranch from that checked commit while it remains inmainhistory; later fast-forward movement is next-release input -
Clear constituent evidence — verify exact-head
pr-a11yandpr-visualevidence for every PR represented by the branch's stable Changesets - Identify codemods — Breaking changes get AST-based codemods
-
Version bump on the branch —
pnpm version-packagesapplies only the cut branch's Changesets, refresh the lockfile, create a PR targeting the release branch - Merge, gate, tag, then publish — merge the version PR to the release branch, dispatch exact-head release checks, tag that commit, and dispatch the Release workflow from the immutable tag
-
Post-release — Create the GitHub Release, sync the released bookkeeping back to
mainwhile preserving post-cut Changesets, verify packages and docsite, publish the required blog post for a minor release, then close the release branch
All non-private packages are published together at the same version (a Changesets fixed group). All 10 publishable packages share the @astryxdesign/* scope and are published to public npm:
| Package | Contents |
|---|---|
@astryxdesign/core |
Components, hooks, utilities, tokens, CSS |
@astryxdesign/cli |
CLI commands: init, swizzle, upgrade, component, docs, template |
@astryxdesign/build |
Shared StyleX build plugins (Babel, PostCSS, Vite, Next) |
@astryxdesign/theme-neutral |
Default theme (ships with core) |
@astryxdesign/theme-butter · theme-chocolate · theme-gothic · theme-matcha · theme-stone · theme-y2k
|
Additional published themes |
@astryxdesign/lab and @astryxdesign/vega, and the apps/internal packages are private and never published. @astryxdesign/storybook and @astryxdesign/sandbox are in the changesets ignore list. (Source of truth: the fixed array in .changeset/config.json.)
Rule: All packages bump to the same version. This is enforced by the fixed group — a single changeset co-bumps every publishable package, so if you're on @astryxdesign/core@0.1.1, you use @astryxdesign/theme-neutral@0.1.1.
At the release window, resolve the target version from the Changesets present on exact current main. Dispatch the existing canonical full-scope check on that exact SHA before creating any release branch:
git fetch origin main
CUT_SHA=$(git rev-parse origin/main)
gh workflow run ci.yml --ref main \
-f operation=release-check \
-f release-branch=main \
-f expected-head="$CUT_SHA"
# Wait for the release-check projection to succeed, then prove the checked SHA
# remains in current main history. Later fast-forward commits are allowed.
git fetch origin main
git merge-base --is-ancestor "$CUT_SHA" origin/mainA failed, cancelled, incomplete, or non-ancestor exact-main receipt holds the cut. After the receipt is green and CUT_SHA is still an ancestor of remote main, create exactly one branch from that checked commit:
VERSION=X.Y.Z
RELEASE_BRANCH="release/v$VERSION"
git push origin "$CUT_SHA:refs/heads/$RELEASE_BRANCH"Create .release/plan.json on that branch before any release mutation. It freezes the release inputs rather than referring back to moving main:
{
"schemaVersion": 1,
"version": "X.Y.Z",
"branch": "release/vX.Y.Z",
"cutSha": "<40-character main SHA at the window>",
"changesets": [
{"path": ".changeset/example.md", "sha256": "<64-character content digest>"}
]
}Create .release/active.json beside it with the canonical digest of that complete plan:
{
"schemaVersion": 1,
"state": "active",
"version": "X.Y.Z",
"branch": "release/vX.Y.Z",
"cutSha": "<40-character main SHA at the window>",
"planDigest": "<64-character canonical plan digest>"
}The release tracker records the same branch, version, cut SHA, exact current branch head, plan digest, and sole owner. There must be exactly one active marker/tracker pair. Protect the branch against force-push and deletion while active. A missing, malformed, mismatched, stale, or ambiguous marker or plan holds the release and cleanup.
The branch is not a rolling release channel. Its version, name, and cut SHA are immutable for the active attempt. The authority creator refuses an existing marker/plan and refuses a version that already has an immutable tag or any publication effect. refresh is only an explicit same-attempt plan revision: it requires an active marker/plan and cannot change version, branch, or cut SHA or revive a terminal attempt. A released or partially published version always requires the next version on a new branch. A terminal aborted attempt with complete publication-absence and cleanup proof may be followed by one fresh same-version attempt at the next daily scheduled tick, or by a specifically activated same-day/off-schedule attempt. It must use a fresh green exact-main cut SHA, head, plan digest, attempt ID, timestamps, and receipt while preserving every prior receipt unchanged.
Everything after the cut operates from this branch and consumes exactly the frozen Changeset paths and hashes. Later main code, package.json edits, canary publication, and Changesets are next-release input; they do not retrigger release CI or invalidate the active branch. A post-cut cherry-pick requires an explicit recorded owner decision. If it changes release inputs, revise the plan and digest explicitly; in every case record the new branch head and rerun the branch gates. Never silently merge or rebase moving main into the release branch.
Before the cut and before each later release mutation, the release loop confirms the authoritative schedule/run record. The scheduled daily tick authorizes only its first normal attempt. Every deviation requires fresh human activation for the named attempt before dispatch. This is a process gate enforced by the release loop and runbook, not a workflow input or cryptographic token. Repository members remain technically able to dispatch the workflows manually; automation must never infer activation, and ambiguity stops automation before dispatch.
The generated version step should take roughly 30–38 seconds. Its PR uses a generated-only CI lane targeted at 3–5 minutes: marker/plan/head validation, allowed-path validation, lockfile install, version/Changeset/changelog checks, and focused codemod tests. After merge, run canonical visual, accessibility, and RTL exactly once on the final release branch head. Artifacts may be reused only when branch, exact head, and plan digest all match; any cherry-pick invalidates them. Metadata-only reuse remains disabled until a full semantic digest and mutation suite proves it safe.
The release gate has two layers. Before the branch exists, exact-main release-check runs the canonical full-scope visual, accessibility, and RTL owners and proves the cut SHA is releasable. After the branch is cut, every pending stable Changeset still needs exact-head CI evidence on its introducing PR. The generated bump does not replace either layer.
Each referenced PR must be merged with:
-
pr-a11ysuccessful, or explicitly skipped by CI because no component scope applies; and -
pr-visual(Stable visual regression) successful, or explicitly skipped because no stable visual scope applies.
A missing check is not a skip, and a new push invalidates prior exact-head
evidence. The release uses an explicit PR reference when present and falls back
to the merge commit that introduced the changeset. Packages marked
canaryOnly: true are outside the stable release and do not block it.
pr-visual owns the scoped before/after/diff report. PR merge governance may
separately require exact-bundle acceptance and promotion; release preflight does
not recreate or retroactively reinterpret that status. Never accept or promote
pixels merely to turn the release green.
pr-a11y owns automated accessibility gating for changed components and fails
on new violations against the checked-in baseline. External assistive-technology
review can provide follow-up evidence, but it is not an Astryx release gate.
Any missing, pending, cancelled, or failed applicable workflow holds the cut and gets its own release-blocker record. The version-bump PR's CI validates generated release bookkeeping; it does not replace the constituent PR receipts.
Every PR that changes published package behavior should include a changeset.
pnpm changeset:newThis Astryx wrapper (around the Changesets CLI):
- Auto-detects which publishable packages your working tree touched and pre-selects them — no hand-enumerating the frontmatter.
- Prompts for a category (
breaking,experimental,component,feat,fix,perf,docs,chore) — this drives changelog grouping and the semver bump. - Captures the contributor(s) — defaults to your
gh/git identity, so credit is recorded at authoring time. -
Derives the bump from the category: a
[breaking]change bumps the minor;[experimental]and every other category bump the patch (see the rule below).
The changeset file is committed with the PR. The Astryx body convention is a [category] headline followed by a @handle line:
---
'@astryxdesign/core': patch
'@astryxdesign/cli': patch
---
[component] Added CommandPalette component and `astryx upgrade` CLI command (#2717)
@yourhandleYou can pass everything as flags for non-interactive use:
pnpm changeset:new --category fix --summary "…" --pr 2717 --contributor yourhandleThe bare pnpm changeset CLI still works, but then you must follow the body convention by hand (and pick the right bump). CI (pnpm check:changesets, part of check:repo) rejects any changeset that is missing a category or contributor, whose bump doesn't match its category (see the rule below), declares a major bump while 0.x, or names a private/ignored package.
⚠️ 0.x semver rule: The bump follows standard semver for the0.x.yrange, where the minor is the stable breaking tier. Under a caret range like^0.1.8, npm resolves anything<0.2.0, so0.1.x → 0.2.0is exactly the bump that signals "stable consumers may break." A[breaking]changeset bumps the minor (0.x.y → 0.(x+1).0). A change confined to a surface that met the experimental contract before its first stable publication uses[experimental]and bumps the patch, even when that experimental surface changes incompatibly. Every other category also bumps the patch.majoris never used while 0.x — it would jump to1.0.0.pnpm changeset:newwrites the right bump from the category you pick;pnpm check:changesetsis the CI backstop that enforces the coupling both ways. (When we hit 1.0, this coupling lifts automatically — it keys off whether publishable packages are still0.x.)
| Change type | Category | Bump (0.x) | Changeset needed? |
|---|---|---|---|
| Experimental API addition, change, or removal | experimental |
patch | Yes — only for an explicitly marked experimental surface |
| New component | component |
patch | Yes |
| New feature/prop | feat |
patch | Yes |
| Bug fix | fix |
patch | Yes |
| Prop rename | breaking |
minor | Yes — also needs a codemod |
| Component removal | breaking |
minor | Yes — also needs a codemod |
| Perf improvement | perf |
patch | Yes |
| Public docsite example or other user-consumed documented package surface | evaluate; usually the affected public package | Yes when user-facing behavior or guidance changes | |
| Internal Storybook story only | — | — | No |
| Sandbox only | — | — | No |
| Tests/tooling only | — | — | No |
- The first body line is
[category] one-line user-facing summary (#PR). - The second line is the contributor handle(s):
@yourhandle(space-separated for multiple). - Use
[experimental]only when the affected API was explicitly marked experimental before its first stable publication. Its summary names the affected surface and its replacement when one exists. If any stable default, behavior, prop, import, CLI command, or machine schema changes incompatibly, use[breaking]instead. - If there's a stable breaking change, use the
[breaking]category (which bumps the minor) and mention the codemod:
---
'@astryxdesign/core': minor
---
[breaking] Renamed `items` prop to `options` on Selector for clarity (#2717)
@yourhandle
**Codemod:** `npx astryx upgrade --codemod rename-selector-items-to-options`Before releasing, audit all changesets for breaking API changes that need codemods.
Only published packages need codemods. @astryxdesign/lab (and vega, and
anything else private) is never published, so it has no consumers to migrate and
is allowed to break freely — a breaking change confined to lab needs no
codemod, and reviewers should not ask for one. See Contributing with AI Assistants: not being importable is exactly what buys lab that freedom.
Promoting a component out of lab into core is additive from the published side — core gains a component, and no published API changed — so it needs no codemod either.
Every row below assumes the change lands in a published package.
| Change | Codemod needed? | Example |
|---|---|---|
| Prop renamed | ✅ Yes |
items → options on Selector |
| Prop removed | ✅ Yes | Remove deprecated prop, add TODO comment |
| Component renamed | ✅ Yes |
HStack → Stack direction="horizontal"
|
| Component removed | ✅ Yes | Replace with new component + direction/variant prop |
| Callback signature changed | ✅ Yes |
onHide: () => void → onOpenChange: (isOpen: boolean) => void
|
| Two props merged into one | ✅ Yes |
onShow/onHide → onOpenChange
|
| New required prop added | If there's a sensible default, no codemod needed | |
| Experimental API changed or removed | Optional when the rewrite is mechanical and materially reduces caller work | |
| New optional prop | ❌ No | Additive, non-breaking |
| New component | ❌ No | Additive |
| Bug fix | ❌ No | Unless it changes expected behavior |
| Import path changed | ✅ Yes |
@astryxdesign/core/Layout → @astryxdesign/core/Stack
|
Anything at all in lab
|
❌ No | Lab is private and breaks freely |
git log v0.0.15..HEAD --oneline -- packages/core/src/ | grep -iE 'rename|remove|refactor|unify|deprecat|breaking'Or check the accumulated changesets:
ls .changeset/*.mdCodemods live under packages/cli/assets/codemods/transforms/. Each transform is a jscodeshift module. See existing version folders for patterns (prop rename, component rename, import rewrite).
Author new codemods in the transforms/next/ folder — NOT a v{VERSION} folder.
Why: the folder that ships a codemod is the target package version that will include the breaking change, and a feature PR usually doesn't know that version yet — the Version Packages PR decides it after changeset version resolves all pending changesets. So codemods stage in next/ (like Changesets stages changelog entries), and the release step promotes them into the real versioned folder.
- Create the transform file in
transforms/next/. - Create a test file in
transforms/next/__tests__/(colocated, following existing patterns). - Add it to
transforms/next/index.mjsso it's exported in the order it should run. -
Do not guess a future
v{VERSION}folder, and do not hand-editcodemods/registry.mjsfor it.
At release time, pnpm version-packages runs scripts/promote-codemod-next.mjs (after changeset version), which copies everything in transforms/next/ — except its README — into transforms/v{new-version}/ and clears next/. The Version Packages PR then reviews the generated version folder and, if it's a brand-new version directory, adds it to codemods/registry.mjs.
pnpm test --run packages/cli/assets/codemods/Contributors are credited from the @handle lines in changesets, and the version bump consumes and deletes those changesets — so any published change without a changeset (or with a missing/wrong @handle) is silently uncredited in the CHANGELOG forever. Before running the bump, confirm the pending changesets account for every published change and credit the right people.
-
List what merged since the last release and what's staged to be credited:
git log vPREV.."$CUT_SHA" --oneline # everything in this release branch ls .changeset/*.md # the changesets that will be consumed
-
Reconcile the two. Every merged PR that changes what a published package ships (see the "When to create a changeset" table) must be represented by a changeset. That includes documentation shipped inside a package, such as
packages/cli/assets/docsand*.doc.mjs. Changes that legitimately need none (repository-only docs, tests, stories, sandbox, CI and tooling, ignored/private/canary-only packages) can be skipped.node scripts/release/changeset-coverage.mjs release --since vPREV --until "$CUT_SHA"runs the same classifier pull-request CI runs on every PR and lists any commit that shipped without one, plus any released CLI JSON id missing at the cut without a[breaking]Changeset orCompatibility:note. -
Check attribution. Each qualifying changeset must carry a correct
@handlecontributor line (space-separated for multiple authors). Watch for old-format changesets that predate the convention, squashed PRs that folded in co-authors, and community contributions where the author isn't the merger.
If anything is missing or miscredited, fix the changeset first — via a PR, before you bump:
git checkout -b docs/changeset-attribution-<pr>
# add a missing changeset, or edit an existing one to add/correct the @handle line
pnpm check:changesets # CI backstop: rejects a missing category/contributor
git commit -am "docs: credit contributor(s) for #<pr>"
gh pr create --title "docs: fix changeset attribution for v<X.X.X>" \
--body "Add/correct changeset attribution so v<X.X.X> credits all contributors."Merge that PR before running pnpm version-packages so the bump consumes the corrected changesets. (If a bad changeset is only caught after the bump, you can still add the @handle to the generated #### Contributors list by hand — see Contributors (automatic) above — but fixing it at the changeset stage is cleaner and keeps CHANGELOG and credit in sync.)
Once all PRs are merged, attribution is verified, and codemods are ready:
pnpm version-packagesThis runs changeset version (bumps versions, generates CHANGELOGs, deletes the consumed changesets) and then scripts/format-changelogs.mjs to format the output.
All publishable packages are a Changesets fixed group, so a single changeset co-bumps all of them to the same version automatically — no need to manually bump packages to match. Only genuinely-affected packages receive a changelog entry; the rest get a clean version-only bump.
The version bump rewrites every publishable package.json version, but pnpm version-packages does not touch pnpm-lock.yaml. The lockfile still pins the old workspace versions (e.g. @astryxdesign/core@0.1.1), so it drifts out of sync the moment you bump. Refresh it and commit it in the same version-bump PR:
pnpm install --lockfile-only # rewrite pnpm-lock.yaml to the new versions; no node_modules churn
pnpm install --frozen-lockfile # verify it matches (this is what CI + release.yml run)
⚠️ Don't skip this. CI and bothrelease.ymljobs install with--frozen-lockfile. A stale lockfile fails withERR_PNPM_OUTDATED_LOCKFILE("specifiers in the lockfile don't match specifiers in package.json"), which blocks the version-bump PR's CI and the publish job. Always commit the refreshedpnpm-lock.yamlalongside the bumpedpackage.json/CHANGELOG files.
pnpm version-packages already runs scripts/format-changelogs.mjs, which rewrites each just-bumped package CHANGELOG into the doc-site format:
| Element | Heading level | Example |
|---|---|---|
| Version |
# 0.0.16 (h1) |
# 0.0.16 |
| Section |
#### Breaking Changes (h4) |
#### Fixes |
| Divider |
--- between versions |
--- |
Category sections render in canonical order (Breaking Changes → Experimental APIs → New Components → New Features → Fixes → Performance → Documentation → Other Changes). The formatter is idempotent and has a --check mode (node scripts/format-changelogs.mjs --check) for CI drift detection. You normally don't touch CHANGELOGs by hand — just review the generated output.
This aligns with the doc site's Markdown rendering which uses headingLevelStart={1}, giving version numbers prominent h1 sizing.
The #### Contributors section is generated from the @handle lines captured in each changeset at authoring time — so it credits the real humans, not the release bot. No git log / gh pr list reconstruction needed.
If a contributor is missing (e.g. an old-format changeset slipped through), add their
@handleto the relevant changeset body and re-runpnpm version-packages, or edit the generated#### Contributorslist directly.
Before committing generated changelogs or release notes, review the complete public text. Remove internal-only references and sensitive metadata while preserving legitimate user-facing changes and contributor credit. The same review applies to the GitHub Release notes and, for a minor release, the public blog. Record the review before the version PR can merge; publishing or syncing without it fails closed.
git checkout -b chore/release-vX.X.X
git add . # bumped package.json + CHANGELOGs + the refreshed pnpm-lock.yaml
git commit -m "chore: version packages for vX.X.X"
git push -u origin chore/release-vX.X.X
gh pr create --base release/vX.X.X --title "chore: version packages for vX.X.X" \
--body "Version bump for release X.X.X"Merge the PR into release/vX.X.X with an exact-head guard. Merging bumps versions on the active branch but does not publish. Record the merged branch head as BUMP_SHA; it is the only commit allowed to own the release tag and release checks. Movement on main is irrelevant. If the release branch itself moves, the old receipt is stale: verify the authorized cherry-pick, update the recorded head, and rerun the branch gates before continuing.
Merging the version PR only prepares the exact release-branch head and runs ordinary PR CI; it does not publish or deploy. The scheduled daily Automation supplies the authoritative schedule record to the release loop, but it does not call GitHub Actions itself. The scheduled loop owner explicitly dispatches the exact-head release-check, creates and pushes the immutable tag after that gate passes, and dispatches release.yml from the tag. For an off-schedule attempt, those same actions cannot begin until the named attempt has fresh human activation.
release.yml publishes stable npm packages only. It does not create the GitHub Release and it does not deploy the public site. After npm verification, the loop creates the GitHub Release explicitly. Public docs, Storybook, and Sandbox deploy through the Vercel project from main after the reviewed bookkeeping sync lands; deploy.yml validates main and does not perform that deployment. Stable publication does not use Pages or Conveyor.
All npm publishing lives in the dedicated Release workflow (release.yml) — not deploy.yml (which runs post-merge validation and publishes nothing to npm). release.yml has two jobs, selected by trigger:
| Job | Trigger | dist-tag | Notes |
|---|---|---|---|
publish (stable) |
workflow_dispatch only |
latest |
Manual, on-demand. Version-gated + idempotent. Supports a dry-run input. |
canary |
push to main (automatic) |
canary |
Publishes 0.x.y-canary.<short-sha> on every push. |
Merging the version-bump PR does NOT publish the stable release. It lands the bumped package.json versions and CHANGELOGs only on the active release branch. First dispatch the release-time visual, accessibility, and RTL projection against the exact recorded branch head. Then tag that same commit and dispatch the Release workflow from the tag:
RELEASE_VERSION=X.X.X
RELEASE_BRANCH="release/v$RELEASE_VERSION"
RELEASE_TAG="v$RELEASE_VERSION"
BUMP_SHA=$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')
PLAN_DIGEST=$(git show "$BUMP_SHA:.release/active.json" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).planDigest))")
# Exact branch-head release checks.
gh workflow run ci.yml --ref "$RELEASE_BRANCH" \
-f operation=release-check \
-f release-branch="$RELEASE_BRANCH" \
-f release-version="$RELEASE_VERSION" \
-f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST"
# After the release-check receipt is green, create the tag on the same commit.
git tag "$RELEASE_TAG" "$BUMP_SHA"
git push origin "$RELEASE_TAG"
# Optional dry-run first: build + resolve what would publish, publish nothing.
gh workflow run release.yml --ref "$RELEASE_TAG" \
-f release-branch="$RELEASE_BRANCH" \
-f release-version="$RELEASE_VERSION" \
-f release-tag="$RELEASE_TAG" \
-f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST" \
-f dry-run=true
# Real publish from the same immutable tag and authority tuple.
gh workflow run release.yml --ref "$RELEASE_TAG" \
-f release-branch="$RELEASE_BRANCH" \
-f release-version="$RELEASE_VERSION" \
-f release-tag="$RELEASE_TAG" \
-f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST"
# Watch it
gh run list --workflow=release.yml -L 3The workflows require exact agreement among the single active marker, frozen plan, release branch, package version, explicit dispatch version/tag/head/plan inputs, checked-out tag commit, and remote branch head. A dispatch from main, an unmarked or wrong branch, a stale head, a wrong tag/version/package/plan, or ambiguous active branches fails before build or publication. The workflows do not require a separate activation token: repository members can dispatch them manually, while the release process requires the scheduled loop or fresh human activation before an off-schedule dispatch. Automation must not infer that activation. Package/version changes and canaries landing on main after the cut are not release inputs. The stable job builds fresh from the immutable tag and never downloads or consumes canary artifacts. You can also dispatch from the GitHub UI with the same complete structural authority tuple.
What the stable publish job does:
- Checks out the immutable tag, revalidates the complete branch/version/tag/head/plan tuple, and builds all packages fresh (
pnpm build); it never consumes canary artifacts - For each public
@astryxdesign/*package, publishes the tag's currentpackage.jsonversion viapnpm publish ... --tag latest --provenance --access public --no-git-checks -
Version-gated + idempotent: any version already on the registry is skipped, so re-running (or running
dry-runfirst) is safe
No manual npm publish needed. No npm auth tokens — anywhere. Publishing uses npm trusted publishing (OIDC):
- There is no
NPM_TOKENsecret in CI or on anyone's machine. Thepublish(andcanary) jobs are grantedid-token: write; pnpm exchanges a short-lived GitHub OIDC token for a registry credential at publish time. - npm trusts exactly one GitHub Actions workflow per package. Because npm matches the OIDC token's
workflow_ref(the calling workflow) and allows only one trusted publisher per package, both stable and canary publishing live in the single filerelease.yml, and the npm trust config must point atrelease.yml. (Re-point withnode scripts/npm/setup-trusted-publishing.mjs --setup-trust --replace --workflow release.yml.) - Every published package carries provenance (
--provenance) — a cryptographic attestation of where and how it was built, linking the npm tarball back to the exact GitHub commit + workflow run.facebook/astryxis public, so provenance works for both stable and canary.
Background: PR #3037 migrated publishing to public npm; PR #3043 replaced the legacy token with OIDC trusted publishing; publishing was later split out of
deploy.ymlinto the dedicatedrelease.ymlso a single npm trusted-publisher config covers both stable and canary, and so thepages-deployconcurrency group can't starve npm publishing.
Trusted publishing is currently configured per-package — there is no org-wide trusted publishing yet. npm also can't register trust on a name that doesn't exist on the registry (unlike PyPI, there is no "pending publisher"). So whenever a new @astryxdesign/* package is added to the publishable set, an npm org owner (e.g. @cixzhang) must bootstrap it before CI can publish it:
npm i -g npm@latest
npm login --registry https://registry.npmjs.org # must be an @astryxdesign org owner
pnpm run setup-trusted-publishing # audit: shows which packages need bootstrap/trust
pnpm run setup-trusted-publishing --bootstrap --setup-trust --workflow release.ymlWhat this does:
-
Bootstrap — publishes a deprecated placeholder
0.0.0-bootstrap.0stub (under thebootstrapdist-tag, neverlatest) to claim the package name on npm. This is why a brand-new package first appears on npm at version0.0.0-bootstrap.0. -
Setup-trust — runs
npm trust github <pkg> --file release.yml --repo facebook/astryxto registerrelease.ymlas the trusted OIDC publisher for that package.
After the first real OIDC publish, the bootstrap stub is superseded by the real version (it lingers only under the deprecated bootstrap dist-tag). Skip this step and the publish of the new package will fail — npm rejects the OIDC publish for a name it has no trust config for. This is a required manual step in the "add a package" path until org-wide trusted publishing exists.
The
pnpm run setup-trusted-publishingscript is a maintainer-only, run-locally tool with an interactive npm session — it is decoupled from CI and never publishes real releases. It supports--dry-runand an audit-only default (no flags). Requires npm ≥ 11.10 fornpm trust. (Note: the script's built-in--workflowdefault may still readdeploy.ymlin older checkouts — always pass--workflow release.ymlto match where publishing now lives.)
-
Verify the immutable tag still resolves to the exact gated release-branch head. The tag was created before publication; do not move or recreate it:
test "$(git rev-parse vX.X.X^{commit})" = "$(git rev-parse origin/release/vX.X.X)"
-
Create the GitHub Release on that tag, with release notes. The Release workflow publishes to npm but does not open a GitHub Release — this is a manual step. The release notes are compiled from the per-package
CHANGELOG.mdentries for the version just shipped, grouped by package in the same canonical category order the formatter uses (Breaking Changes → Experimental APIs → New Components → New Features → Fixes → Performance → Documentation → Other Changes — see CHANGELOG formatting), plus the contributor list and a compare link.# Pull the version's section out of each package CHANGELOG into one file : > /tmp/notes.md for pkg in core cli build lab; do f="packages/$pkg/CHANGELOG.md" section=$(awk '/^# X\.X\.X$/{flag=1;next} /^# [0-9]/{if(flag)exit} flag' "$f") [ -n "$(echo "$section" | tr -d '[:space:]')" ] || continue echo "## @astryxdesign/$pkg" >> /tmp/notes.md echo "$section" >> /tmp/notes.md done # …then edit /tmp/notes.md: dedupe the per-package Contributors lists into one, # and append a compare link. echo "**Full Changelog**: https://github.com/facebook/astryx/compare/vPREV...vX.X.X" >> /tmp/notes.md gh release create vX.X.X --title vX.X.X --notes-file /tmp/notes.md --verify-tag
Public editorial gate: before release notes, generated changelogs, or a release blog are committed, published, or synced, a human reviews the complete public text. Remove internal-only references and sensitive metadata without deleting legitimate user-facing changes or contributor credit. Record this review before publication; missing or incomplete review holds the release. This is an editorial policy gate, not a literal-specific scanner.
-
Sync released bookkeeping back to
main. Create one release-ownedchore/sync-vX.Y.Z-to-mainPR from currentmain. Copy the exact released changelogs, release notes, and promoted codemods from the immutable tag; delete only the frozen Changesets recorded in its plan. Apply package versions and generated internal@astryxdesign/*pins field-by-field onto currentmainmanifests, preserving every other newer manifest field. Refresh the lockfile from that preserved current-main manifest state rather than copying the tag lockfile wholesale. Preserve every post-cut Changeset and product commit. Therelease-syncCI lane fails closed unless the diff is bookkeeping-only, each frozen Changeset deletion is exact and hash-bound, every post-cut Changeset is byte-identical, tag-owned text/codemod outputs equal the tag, manifest changes are version-only, the lockfile is coherent, the tag equals the still-active release branch head, and a second/idempotent run produces no extra mutation. Merge this PR before closing the release. -
Verify packages and deployment. From a detached checkout of the immutable tag, build and compare every
npm packfile byte-for-byte with the corresponding public npm tarball (node scripts/release/verify-published.mjs --version X.Y.Z). Also check every public package'slatestdist-tag and provenance, the GitHub Release source commit, and the public docsite/changelog built after the bookkeeping sync. Expected content comes from the active branch marker and tag—not from version diffs on movingmain. Record the branch, tag commit, plan digest, workflow run, package parity results, and deployed URL in the release tracker. -
For a minor release, publish the required docsite blog post. The post is a concise
type: 'update'digest covering the complete range from the previous minor through the new minor. It must include the exact install/upgrade command, every breaking migration, the highest-impact builder changes, contributor credit, and links to the GitHub Release. Prepare and validate it before the cut, but do not merge it until npm and the GitHub Release exist. After merge, verify the deployed public URL returns successfully. Keep the release tracker open until that check passes; the npm publication alone does not complete a minor release. -
Update agent docs in consumer projects (the agent-docs step is now part of
init):npx astryx init
-
Notify consumers — post in the Astryx chat spaces with a summary of changes and the upgrade command.
-
Close the version milestone, open the next one. Each release has a GitHub milestone (
X.Y.Z) that the Night Watch PM role stamps onto resolved issues so reporters know which version ships their fix (see Night Watch PM section 5). On publish, close the shipped milestone and create the next one so PM keeps stamping going forward:# Close the milestone for the version just shipped NUM=$(gh api repos/facebook/astryx/milestones --jq '.[] | select(.title=="X.Y.Z") | .number') gh api -X PATCH repos/facebook/astryx/milestones/$NUM -f state=closed # Create the next version's milestone (compute NEXT from the pending changeset bump levels) gh api -X POST repos/facebook/astryx/milestones \ -f title="NEXT" -f state=open \ -f description="Next release. Issues whose fixes merged to main after vX.X.X."
Before closing the milestone, reconcile its still-open issues. Each open issue is one of three cases:
-
Fully shipped (the issue's whole ask merged and is in the tag): close it as
completed with a "Shipped in
X.Y.Z" comment, as part of normal post-merge hygiene. - Partially shipped (a feature/umbrella issue where only part of the ask landed, e.g. one of several requested changes): do NOT close it. Comment what shipped vs. what remains, and move it to the new milestone so the remainder is tracked forward.
- Slipped (didn't make the release at all): move it to the new milestone.
Only close the milestone once it has no open issues left (everything is either closed or moved forward).
-
Fully shipped (the issue's whole ask merged and is in the tag): close it as
completed with a "Shipped in
-
Close and prune the active branch. Only after npm, the immutable tag, GitHub Release, deployed package/docsite surfaces, detached-tag published-byte parity, the generated consumed-Changeset
mainsync, milestone reconciliation, durable verification record, closed tracker, and any minor-release blog are complete: record terminal verification, mark the checked-in marker closed, clear cleanup protection, delete the remote branch, and remove the local worktree through the supported close flow. Active, failed, partially published, rollback-investigation, and unresolved branches remain protected. Generic cleanup never performs release closure.
After the stable release is published to public npm, downstream consumers need to be updated to the new version. Maintainers who mirror Astryx into a downstream project bump their pinned version there as part of their own process.
Once a consumer is on the new version:
- When an AI agent runs
astryx component <Name>, the CLI readsLATEST_VERSIONvia theastryx.versionFileconfig - If the installed version is older, the CLI prints an upgrade nudge
- The user decides when to upgrade — the agent won't upgrade automatically
Announce the release to the Astryx community wherever consumers follow along.
Write every line item from the builder's perspective — how they experience the change in their product or dev workflow.
Rules:
- Lead with the outcome — what's better for the builder now?
- One sentence max per highlight
- Skip internal-only changes
- Breaking changes: reassure, don't alarm — mention the codemod handles it
- Group related fixes
The canary job in release.yml publishes a canary version on every push to main (automatically — this is the one publish path that needs no manual dispatch). It derives the canary from main's current package version, builds in that ephemeral checkout, and updates only the @canary dist-tag. It does not read, write, authorize, block, or produce receipts for the active stable marker/plan. A canary after a release cut has no effect on that release. facebook/astryx is public, so canary publishes carry --provenance.
<base-version>-canary.<short-sha>
e.g. 0.0.15-canary.fd7c751
npm install @astryxdesign/core@canarySafety guarantee: npm install @astryxdesign/core (no tag) always gets the stable @latest release. Canary versions live on a separate dist-tag.
| Scenario | Use canary? |
|---|---|
| Test codemods on internal apps before a stable release | ✅ Yes |
| Validate a breaking change on a feature branch | ✅ Yes |
| Quick fix for a codemod bug post-release | ✅ Yes |
| Routine release with no breaking changes | ❌ No — just publish stable |
Both stable and canary publishes attach provenance — a signed, cryptographic attestation that links each npm tarball to the exact GitHub commit and workflow run that built it. Provenance requires a public source repo; facebook/astryx is public, so every publish from release.yml carries provenance automatically with no further action. The stable publish job publishes per package with --provenance; a publish that can't attach provenance fails the job rather than silently shipping an unattested package.
- Release gate clear (Step 0): visual
pass(or everychangedshot judged and the intentional ones promoted with a reason), a11y clean, and the run is from the last 24 hours - Attribution coverage verified: pending changesets account for every published change since
vPREV, each with a correct@handle(missing/incorrect ones fixed via PR before the bump) - All breaking changes have codemods with tests
- CHANGELOGs follow the standard format
- All packages at the same version number
- Any new package has been bootstrapped + trust-configured by an npm org owner (
pnpm run setup-trusted-publishing ... --workflow release.yml) - Previous release is tagged
- Lockfile refreshed (
pnpm install --lockfile-only) and committed in the version-bump PR;pnpm install --frozen-lockfilepasses - Version bump PR created and reviewed
- Exactly one active
release/vX.X.Xmarker/tracker pair exists; branch, version, cut SHA, frozen Changeset hashes, plan digest, and recorded head all agree - Dispatch
ci.ymlrelease-checkwith explicit branch, version, expected head, and plan digest; visual, a11y, and RTL receipts bind the same authority tuple - Tag the same branch head and verify tag source equality:
git tag vX.X.X "$BUMP_SHA" && git push origin vX.X.X - Dispatch
release.ymlfrom the tag with explicit branch, version, tag, head, and plan-digest inputs (optionally dry-run first) - Verify all public packages: byte parity with the tag, versions,
latestdist-tags, provenance, and source commit - GitHub Release created on the immutable tag with compiled release notes (
gh release create vX.X.X --notes-file …) - Released bookkeeping synced to
mainwithout consuming post-cut Changesets - Public docsite/changelog deployment verified from the tagged release content
- Minor releases only: public docsite blog post merged after npm/GitHub Release publication, and its deployed URL verified successfully
- Version milestone
X.X.Xclosed and the next version's milestone created (PM role stamps issues onto it) - Active release tracker closed and terminal verification recorded; branch protection/cleanup exemption removed, then remote/local release branch pruned through the supported close flow
- Internal sync diff submitted
- Release announcement posted
Never republish an old version number. npm versions, release tags, GitHub Releases, deployments, and partially published versions are immutable and permanently block semantic-version reuse. A retry before terminal closure remains on the still-protected original attempt's branch and frozen plan, but it is a deviation and requires fresh one-use human activation for that named attempt. A completely aborted attempt that provably produced none of those effects is different from a published version: after its terminal receipt is durable and its branch, worktree, and active marker are absent, an explicitly activated new attempt may recreate the conventional release/vX.Y.Z branch from fresh exact main. It must receive a new attempt ID, cut SHA, head, plan, timestamps, and receipt; the aborted receipt is never edited or reopened. Any correction to a released or partially published version uses a new semantic version with a new branch, plan, tag, publication, verification record, and bookkeeping sync. Historical or legacy tags with no active lifecycle cannot bypass these rules or the stable validator.
# Add a changeset to your PR (auto-detects packages, prompts category + contributor)
pnpm changeset:new
# Check pending changesets
ls .changeset/*.md
# Validate changesets (category, contributor, category↔bump coupling) — also runs in CI
pnpm check:changesets
# Version bump (changeset version + CHANGELOG formatting)
pnpm version-packages
# Refresh the lockfile to the new versions (required — version-packages doesn't) and verify
pnpm install --lockfile-only
pnpm install --frozen-lockfile
# Create the version-bump PR targeting release/vX.X.X; merging bumps only that branch.
# Final exact-head branch gate.
RELEASE_VERSION=X.X.X
RELEASE_BRANCH="release/v$RELEASE_VERSION"
RELEASE_TAG="v$RELEASE_VERSION"
BUMP_SHA=$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')
PLAN_DIGEST=$(git show "$BUMP_SHA:.release/active.json" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).planDigest))")
gh workflow run ci.yml --ref "$RELEASE_BRANCH" \
-f operation=release-check -f release-branch="$RELEASE_BRANCH" \
-f release-version="$RELEASE_VERSION" -f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST"
# Tag the gated branch head, then dry-run and publish from that immutable tag.
git tag "$RELEASE_TAG" "$BUMP_SHA" && git push origin "$RELEASE_TAG"
gh workflow run release.yml --ref "$RELEASE_TAG" \
-f release-branch="$RELEASE_BRANCH" -f release-version="$RELEASE_VERSION" \
-f release-tag="$RELEASE_TAG" -f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST" -f dry-run=true
gh workflow run release.yml --ref "$RELEASE_TAG" \
-f release-branch="$RELEASE_BRANCH" -f release-version="$RELEASE_VERSION" \
-f release-tag="$RELEASE_TAG" -f expected-head="$BUMP_SHA" \
-f plan-digest="$PLAN_DIGEST"
gh run list --workflow=release.yml -L 3 # watch
# (canary publishes automatically on every push to main — no dispatch needed)
# After publish: create the GitHub Release, sync bookkeeping to main, verify packages/docsite,
# then close the active release tracker and branch.
gh release create vX.X.X --title vX.X.X --notes-file /tmp/notes.md --verify-tag
# Adding a NEW package: an npm org owner bootstraps + registers trust (one-time, local)
pnpm run setup-trusted-publishing # audit
pnpm run setup-trusted-publishing --bootstrap --setup-trust --workflow release.yml # claim name + register release.yml as trusted publisher
# Consumers upgrade
npx astryx upgrade --apply- Distribution — Packages, versioning, source and dist bundles
- API Conventions — Naming and prop conventions that inform when codemods are needed
Start here Astryx Philosophy Contributing with AI Assistants Contributing
Architecture System Architecture Architecture Cheat Sheet Theming Infrastructure Distribution
Building a component Component Lifecycle Component Authoring Guide API Conventions Design Conventions
Quality Component Audit Rubric Accessibility Checklist AST-009 assistive-technology verification
Operations Release Process Night Watch Overview