Skip to content

Release Process

Cindy Zhang edited this page Oct 8, 2026 · 34 revisions

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/* through release.yml with trusted publishing (OIDC); there is no NPM_TOKEN. Read npm latest and the newest GitHub Release for the current published version rather than copying a version number into this page.

Public release policy

  • 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-check on an exact commit from current main and waits for visual, accessibility, and RTL success. The cut SHA must equal that green receipt's head and remain in current remote main history. 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: true are 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-a11y and pr-visual CI 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.Z branch from that exact checked SHA while it remains in current main history. The checked SHA need not still be the tip; later fast-forward commits are next-release input. Commit .release/active.json and .release/plan.json on 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 checked main commit 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 main changes 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.Z tag, release-check receipts, dry run, publish run, GitHub Release, and deployment verification. Never regenerate from moving main, and never publish stable from main.
  • CI authority: the canonical release-check has two read-only modes. Before a cut, exact-main mode accepts only main when the dispatch ref, checkout SHA, and requested head agree and that exact checked SHA remains an ancestor of current remote main at 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 rejects main, 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. main continues 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 an aborted terminal 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 to main, 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.

Overview

Astryx releases all packages at the same version number for clear compatibility. The release process:

  1. Prove and cut the release head — run the canonical full-scope check on an exact commit from current main, then create the single marked release/vX.Y.Z branch from that checked commit while it remains in main history; later fast-forward movement is next-release input
  2. Clear constituent evidence — verify exact-head pr-a11y and pr-visual evidence for every PR represented by the branch's stable Changesets
  3. Identify codemods — Breaking changes get AST-based codemods
  4. Version bump on the branch — pnpm version-packages applies only the cut branch's Changesets, refresh the lockfile, create a PR targeting the release branch
  5. 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
  6. Post-release — Create the GitHub Release, sync the released bookkeeping back to main while preserving post-cut Changesets, verify packages and docsite, publish the required blog post for a minor release, then close the release branch

Packages

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.


Step 0: Prove and mark the release branch

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/main

A 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.


Step 1: The Release Gate

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-a11y successful, 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.


Step 1: Changesets

Every PR that changes published package behavior should include a changeset.

Creating a changeset

pnpm changeset:new

This Astryx wrapper (around the Changesets CLI):

  1. Auto-detects which publishable packages your working tree touched and pre-selects them — no hand-enumerating the frontmatter.
  2. Prompts for a category (breaking, experimental, component, feat, fix, perf, docs, chore) — this drives changelog grouping and the semver bump.
  3. Captures the contributor(s) — defaults to your gh/git identity, so credit is recorded at authoring time.
  4. 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)
@yourhandle

You can pass everything as flags for non-interactive use:

pnpm changeset:new --category fix --summary "…" --pr 2717 --contributor yourhandle

The 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 the 0.x.y range, where the minor is the stable breaking tier. Under a caret range like ^0.1.8, npm resolves anything <0.2.0, so 0.1.x → 0.2.0 is 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. major is never used while 0.x — it would jump to 1.0.0. pnpm changeset:new writes the right bump from the category you pick; pnpm check:changesets is 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 still 0.x.)

When to create a changeset

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

Changeset conventions

  • 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`

Step 2: Identify Codemods

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.

What needs a codemod

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 ⚠️ Maybe If there's a sensible default, no codemod needed
Experimental API changed or removed ⚠️ When useful 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

How to audit

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/*.md

Writing a codemod

Codemods 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.

Registering a codemod

  1. Create the transform file in transforms/next/.
  2. Create a test file in transforms/next/__tests__/ (colocated, following existing patterns).
  3. Add it to transforms/next/index.mjs so it's exported in the order it should run.
  4. Do not guess a future v{VERSION} folder, and do not hand-edit codemods/registry.mjs for 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.

Testing codemods

pnpm test --run packages/cli/assets/codemods/

Step 3: Version Bump

Verify attribution coverage (before you bump)

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.

  1. 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
  2. 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/docs and *.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 or Compatibility: note.
  3. Check attribution. Each qualifying changeset must carry a correct @handle contributor 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.)

Run the bump

Once all PRs are merged, attribution is verified, and codemods are ready:

pnpm version-packages

This 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.

Update the lockfile (required)

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 both release.yml jobs install with --frozen-lockfile. A stale lockfile fails with ERR_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 refreshed pnpm-lock.yaml alongside the bumped package.json/CHANGELOG files.

CHANGELOG formatting (automatic)

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.

Contributors (automatic)

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 @handle to the relevant changeset body and re-run pnpm version-packages, or edit the generated #### Contributors list directly.

Public editorial review

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.

Create a version bump PR

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.


Step 4: Publish (manual dispatch of the Release workflow)

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 3

The 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:

  1. 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
  2. For each public @astryxdesign/* package, publishes the tag's current package.json version via pnpm publish ... --tag latest --provenance --access public --no-git-checks
  3. Version-gated + idempotent: any version already on the registry is skipped, so re-running (or running dry-run first) is safe

No manual npm publish needed. No npm auth tokens — anywhere. Publishing uses npm trusted publishing (OIDC):

  • There is no NPM_TOKEN secret in CI or on anyone's machine. The publish (and canary) jobs are granted id-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 file release.yml, and the npm trust config must point at release.yml. (Re-point with node 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/astryx is 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.yml into the dedicated release.yml so a single npm trusted-publisher config covers both stable and canary, and so the pages-deploy concurrency group can't starve npm publishing.

Adding a NEW package (one-time bootstrap by an npm org owner)

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.yml

What this does:

  1. Bootstrap — publishes a deprecated placeholder 0.0.0-bootstrap.0 stub (under the bootstrap dist-tag, never latest) to claim the package name on npm. This is why a brand-new package first appears on npm at version 0.0.0-bootstrap.0.
  2. Setup-trust — runs npm trust github <pkg> --file release.yml --repo facebook/astryx to register release.yml as 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-publishing script 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-run and an audit-only default (no flags). Requires npm ≥ 11.10 for npm trust. (Note: the script's built-in --workflow default may still read deploy.yml in older checkouts — always pass --workflow release.yml to match where publishing now lives.)


Step 5: Post-Release

  1. 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)"
  2. 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.md entries 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.

  3. Sync released bookkeeping back to main. Create one release-owned chore/sync-vX.Y.Z-to-main PR from current main. 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 current main manifests, 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. The release-sync CI 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.

  4. Verify packages and deployment. From a detached checkout of the immutable tag, build and compare every npm pack file byte-for-byte with the corresponding public npm tarball (node scripts/release/verify-published.mjs --version X.Y.Z). Also check every public package's latest dist-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 moving main. Record the branch, tag commit, plan digest, workflow run, package parity results, and deployed URL in the release tracker.

  5. 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.

  6. Update agent docs in consumer projects (the agent-docs step is now part of init):

    npx astryx init
  7. Notify consumers — post in the Astryx chat spaces with a summary of changes and the upgrade command.

  8. 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).

  9. 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 main sync, 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.


Step 6: Sync Downstream Consumers

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.

How this enables the update loop

Once a consumer is on the new version:

  1. When an AI agent runs astryx component <Name>, the CLI reads LATEST_VERSION via the astryx.versionFile config
  2. If the installed version is older, the CLI prints an upgrade nudge
  3. The user decides when to upgrade — the agent won't upgrade automatically

Step 7: Release Announcement

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:

  1. Lead with the outcome — what's better for the builder now?
  2. One sentence max per highlight
  3. Skip internal-only changes
  4. Breaking changes: reassure, don't alarm — mention the codemod handles it
  5. Group related fixes

Canary Builds

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.

Version format

<base-version>-canary.<short-sha>
e.g. 0.0.15-canary.fd7c751

How consumers install a canary

npm install @astryxdesign/core@canary

Safety guarantee: npm install @astryxdesign/core (no tag) always gets the stable @latest release. Canary versions live on a separate dist-tag.

When to use canaries

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

Provenance

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.


Pre-Release Checklist

  • Release gate clear (Step 0): visual pass (or every changed shot 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-lockfile passes
  • Version bump PR created and reviewed

After merge — publish + post-release

  • Exactly one active release/vX.X.X marker/tracker pair exists; branch, version, cut SHA, frozen Changeset hashes, plan digest, and recorded head all agree
  • Dispatch ci.yml release-check with 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.yml from 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, latest dist-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 main without 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.X closed 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

Correcting an Older Version

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.


Quick Reference

# 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

Related

  • Distribution — Packages, versioning, source and dist bundles
  • API Conventions — Naming and prop conventions that inform when codemods are needed

Clone this wiki locally