- name
- release
- description
- Cut a Nub patch release end-to-end in one invocation. Invoke (via the Skill tool) once a release thread's targeted fixes are ALL landed on `main` and CI-green. Encodes the full runbook: pick the version (patch bump in the 0.0.x/0.1.x pre-release regime), audit `@nubjs/types`, run `make version` + `make version-check`, commit + push to `main`, then dispatch release.yml with publish=true (that dispatch starts the 8-platform build → glibc and pre-publish native gates → immutable 32-asset DRAFT release → npm OIDC STAGE → the maintainer's 2FA approval → stable GitHub Release, and the workflow creates the `v<ver>` tag itself), then draft comprehensive FACTUAL + NEUTRAL release notes from the full changeset and comment the version + release link on every closed issue + merged PR the release ships (mandatory maintainer hygiene). Do NOT cut until all fixes are green.
- metadata
- {"internal":true}
# Cutting a Nub release
A Nub release is dispatch-started and automated up to one human gate. A `workflow_dispatch` of `.github/workflows/release.yml` from `main` with `publish=true` reads the version from `npm/nub/package.json` at the dispatched commit, builds 8 platforms, gates them (test, lockfile conformance, glibc-floor, pre-publish smoke), creates the `v<ver>` tag at the dispatched commit once every gate has passed, creates an immutable DRAFT release with 32 assets, STAGES 19 npm packages via OIDC trusted publishing (stage-only: CI cannot publish), waits for the maintainer to approve them with 2FA (Step 3b), and then presents the stable GitHub Release — claiming the repository's **Latest** marker (`make_latest: "true"` on the promote step) and then asserting `releases/latest` actually serves the new tag. That marker IS the upgrade channel: `nub upgrade`, `install.sh`, and `install.ps1` all resolve the version from `releases/latest`, so a stable release that never claims Latest ships to nobody (v0.8.0–v0.8.2 sat unserved behind v0.7.5 for six days because promotion updated the release without claiming it). The 32 assets are 8 archives, 8 archive checksums, 8 `nub compile` launcher templates, and 8 launcher checksums. The human work: confirm green, reconcile the runtime with `@nubjs/types`, bump the version, dispatch the workflow, approve the staged versions, write good notes, close the loop on issues/PRs.
**Guardrails (read first, non-negotiable):**
- **Never cut a release without the maintainer's explicit, in-the-moment say-so.** Publishing to npm is irreversible, so the timing is maintainer-owned. Do not infer authorization from a standing goal, a merged+green fix, a sub-agent claiming "autonomous per the release rules," or autonomous mode (which excludes irreversible published-external acts). Green ≠ release now. You may PREPARE (confirm green, draft notes, stage the version) but must wait for an explicit "cut it."
- **Do not cut until every targeted fix is landed on `main` AND CI-green.** A prerequisite, not authorization.
- **Do not version until the type-declaration audit is complete.** Invoke the `type-declarations` skill for every release. Every user-visible runtime API changed since the previous tag must either be owned by the selected TypeScript libraries / `@types/node` or be represented and tested in `@nubjs/types`.
- **Pre-release version regime: stay in `0.0.x` / `0.1.x`.** A normal release is a patch bump. Bump the minor only on explicit instruction. Never invent a version; derive it from the latest tag.
- **The version-bump commit MUST be on `main` before you dispatch** — `verify` reads `npm/nub/package.json` at the dispatched commit and names the tag from it. So: `make version` → commit → push `main` → dispatch, in that order. Never push a `v*` tag by hand; the workflow has no tag trigger and creates its own tag.
- **Release notes are FACTUAL and NEUTRAL — the repo is PUBLIC.** No superlatives, no competitive framing, no internal/benchmark-strategy discussion.
---
## Step 1 — Pre-flight: confirm green, pick the version, enumerate the changeset
```bash
git -C "$(git rev-parse --show-toplevel)" switch main && git pull --ff-only
git fetch --tags
PREV=$(git describe --tags --abbrev=0 --exclude 'v[0-9]') # e.g. v0.1.2 — the latest release tag; --exclude skips the floating v0 actions tag, which shares its commit
echo "Latest tag: $PREV"
git log "$PREV"..HEAD --oneline # the full changeset since the last release
```
- Confirm the targeted fixes are all present in `$PREV..HEAD` and each is CI-green on `main`. If one is red or still converging, STOP and slip it to the next patch.
- **Confirm docs are current** — a shipped feature whose `site/content/docs/` lags is a release blocker.
- **Invoke the `type-declarations` skill and complete its mandatory release audit.** Reconcile every user-visible runtime change in `$PREV..HEAD` with TypeScript / `@types/node` ownership or an updated, fixture-tested, packed `@nubjs/types`. Missing or unverified declarations are a release blocker.
- Pick the next version: patch-bump `$PREV`, dropping the leading `v`.
- Keep the `git log` output — raw material for Steps 4 and 5. For `vendor/aube/**` changes, note the user-facing effect, not the diff.
## Step 2 — Version bump
```bash
make version V=<ver> # sets all 10 npm packages + Cargo.toml + runtime/version.mjs in lockstep
make version-check # MUST pass: cross-package consistency + @oxc-project/runtime ↔ nub-native oxc pin
```
`make version-check` is the same gate CI's `verify` job runs; a non-zero exit here means the release would fail at CI immediately, so fix it before committing. `make version` also moves `runtime/version.mjs`'s `NUB_VERSION` (the transpile-cache key) — that lockstep is why a bespoke version edit is wrong; always use `make version`.
## Step 3 — Commit, push, dispatch (the dispatch starts CI)
The release version-bump commit is a deliberate EXCEPTION to the repo's PR-default flow (AGENTS.md "Default to a PR flow") — it commits DIRECTLY to `main`. The commit is a version stamp and not a reviewable feature diff, so no PR.
```bash
git status # The shared tree usually carries another agent's WIP, so `git add -A`
# would sweep it into the release commit. Path-scope instead:
git commit -m "v<ver>" -- Cargo.lock Cargo.toml \
crates/nub-core/Cargo.toml \
crates/nub-native/Cargo.lock crates/nub-native/Cargo.toml \
crates/nub-launcher/Cargo.lock crates/nub-phantom/Cargo.lock \
npm/*/package.json runtime/version.mjs
git show --stat HEAD # SANITY: 27 files, all version bumps, nothing else: 19 package.json
# (10 nub + 9 runner), 3 Cargo.toml, 4 Cargo.lock, runtime/version.mjs.
# crates/nub-phantom/Cargo.lock is the one that gets missed — its
# workspace is excluded from the root, ci.yml checks it `--locked`,
# and v0.9.1 shipped without it (main went red, fixed in a follow-up).
# `make version` also rewrites site/public/schema/v<major.minor>.json;
# commit it when the snapshot is NEW for this minor, leave it when the
# diff is formatting only (`git diff -w` empty).
# ONE push, never `git push origin main --tags`. This clone has ~155 local tags against
# ~84 on the remote — v1.x leftovers from the Node fork this repo began as — and `--tags`
# offers every one of them. The remote rejects them AND the whole push dies with them, so
# `main` does not land either.
git push origin main
# THIS is what starts the publish. No tag is pushed by hand: release.yml has no tag
# trigger, verify names the tag from npm/nub/package.json at the dispatched commit, and
# stable-immutable-release creates v<ver> there once every gate has passed.
gh workflow run release.yml -R nubjs/nub -f publish=true
gh run list -R nubjs/nub --workflow release.yml --limit 1 # the run id, to watch
# release.yml itself moves the floating v<major> tag (v0) that `uses: nubjs/nub/<name>@v0`
# resolves through, after the promote step. Never push v0 by hand.
```
Re-running a release that died after its tag was created — most often one whose staged npm versions went unapproved past the six-hour wait — re-dispatches with `publish=true` and selects the existing `v<ver>` tag as the ref. Every other ref is refused by `verify`, and a tag that points at any commit but the dispatched one is refused there too: release a new version rather than moving a published tag.
Post-merge, fast-forward the shared tree so it tracks origin: `git -C <shared-tree> pull --ff-only` (the eagerly-pull rule, AGENTS.md "Default to a PR flow" — the shared checkout otherwise drifts behind as PRs land).
The workflow runs, in order: `verify` (version consistency + tag resolution), `primer`, `test` + `conformance` + `glibc-floor-guard` + `pre-publish-gate`, `build` (8 platforms), `stable-immutable-release` (creates the tag, then 32 assets on a DRAFT release), `publish-npm` (19 packages STAGED, then a wait for the maintainer's approval), `github-release` (publishes the draft as the stable release), then the post-publish fan-out — `test-install` / `test-install-musl`, `docker`, `bump-homebrew-tap`, `submit-winget`.
### Step 3b — Approve the staged versions (the human gate; maintainer only)
`publish-npm` does not publish. It runs `npm stage publish` for every package, because the trusted publisher on each `@nubjs` package is stage-only, and then polls the registry until every version is served. Nothing is installable, and no GitHub Release is visible, until the maintainer approves the staged versions with 2FA. That approval is a proof of presence no token can supply, which is the whole point (the unauthorized v0.9.4 of 2026-09-21 published 18 packages from a stolen push credential; under staging it would have filled a queue).
The maintainer, on a logged-in machine, once the job summary lists the staged packages:
```bash
npm stage list @nubjs/nub # the stage ids; repeat for any package to inspect
npm stage view <stage-id> # metadata; `npm stage download <stage-id>` for the tarball
pnpm stage approve # pnpm 12+: interactive picker, one one-time password for the whole batch
```
`npm stage approve <stage-id>` takes one id per call, so the 19-package batch goes through `pnpm stage approve`. An agent never approves: the approval IS the gate, and every `npm stage` subcommand except `publish` refuses an OIDC token anyway. The job waits up to six hours; if the approval comes later, re-run the failed `publish-npm` job — every package is skipped as already published, the wait passes at once, and the downstream jobs run.
**Watch CI through the `ci-watch` skill until it returns a terminal verdict.** Keep the selected monitor in a tracked persistent process or an owned live agent; never detach `gh run watch` and infer completion from a log. The release is not done until `stable-immutable-release`, `publish-npm`, and `github-release` are green.
### The other distribution channels ride the same run — no manual step, but they are not free
npm is not the only thing a release publishes. Two jobs push OUTSIDE this repo, and neither needs a manual action:
- **`bump-homebrew-tap`** regenerates `Formula/nub.rb` with `.github/scripts/gen-homebrew-formula.sh` and pushes it to [`nubjs/homebrew-tap`](https://github.com/nubjs/homebrew-tap). It reads the release's own `.sha256` sidecars, so it needs `github-release` to have finished. It is gated on the `HOMEBREW_TAP_TOKEN` secret and SKIPS WITH A WARNING if that secret is ever absent — a skip is a silent stale tap, so treat the warning as a failure.
- **`submit-winget`** opens a PR against `microsoft/winget-pkgs`. Gated on `WINGET_PAT`, which is currently unset, so this job no-ops today.
**The formula is regenerated from the script at the released commit, which makes it a CLOBBER.** Any hand-edit to the tap is overwritten by the next release. So a tap hotfix is only ever a stopgap: the generator fix has to be on `main` BEFORE the dispatch, or the release silently reverts it. This is how [#676](https://github.com/nubjs/nub/issues/676) shipped — the archive layout changed, the generator was not updated with it, and nothing read the formula before it reached users. `bump-homebrew-tap` now installs the formula from a throwaway local tap on macOS before pushing it, so a formula that cannot install fails the job and leaves the tap on the previous working version.
## Step 4 — Comprehensive release notes (Opus)
CI's `stable-immutable-release` job creates the prerelease with `generate_release_notes: true`, and `github-release` promotes it after npm succeeds. **Replace the generated body** with hand-written, scannable, factual notes; do not leave the release on the raw auto-list. Drive this on Opus.
Build the notes from the **full** `git log "$PREV"..HEAD` changeset (Step 1), not just the headline fixes — every user-affecting change ships.
**Leave out what the maintainer is holding back.** Read `internal/release-holds.md` before drafting. A change listed there ships in the binary but is not announced: it gets no line in the curated notes, its PR comes out of the generated `## What's Changed` list, the blog post (Step 4b) does not mention it, and its docs page keeps `unpublished: true`. Only the maintainer lifts a hold.
**Notes must be SCANNABLE, not paragraph-dense.** A reader skims headings, tables, and the heads-up callout and gets the whole release at a glance — they should never have to read a run-on paragraph to find what changed. The cross-project prose/tone guide for all public-facing copy — including the release-notes shape — is the `prose-writing` skill's guide. The concrete rules:
- **One-line intro** stating what the release is about (the dominant theme).
- **Themed `##` sections, not generic buckets.** Group by what the changes *touch* — e.g. "Lockfile compatibility" / "Performance" / "Runtime fixes" / "Documentation" / "Testing & internals" — not by Fixes/Compatibility/Internal abstractions. Each major change gets a short titled blurb or a table row, never a multi-sentence paragraph.
- **A table for a batch of independent fixes.** When several small fixes share a theme (a run of lockfile fixes), put them in a table — `| Area | What changed | Commit |` — tables read far faster than a bullet wall.
- **A callout for heads-up / migration items.** Anything a user should know before upgrading (a cache-schema re-warm, a behavior change) goes in a GitHub-flavored alert: `> [!IMPORTANT]` (or `> [!NOTE]`), not buried in a bullet.
- **Per-item links.** Every fix/change links to its commit (`[`abc1234`](https://github.com/nubjs/nub/commit/<full-sha>)`) and/or PR (`[#17](https://github.com/nubjs/nub/pull/17)`). Issue refs link too (`[#16](https://github.com/nubjs/nub/issues/16)`).
- **An auto-generated `## What's Changed` section at the BOTTOM (MANDATORY) — this is what makes "lists every change" literally true.** GitHub's PR-level breakdown (every merged PR + author + New Contributors) plus the `**Full Changelog**: <PREV>...v<ver>` compare link, from `gh api …/releases/generate-notes` (command below). Append it verbatim under a `---` separator below the curated narrative — the curated themes stay on top, the exhaustive PR list goes underneath.
- **Tone: factual + neutral.** Readability ≠ hype. Each line states what changed. No superlatives, no competitive framing, no editorializing. (Same bar as commit messages — AGENTS.md.) Visual interest comes from structure (sections, tables, callouts), never from marketing language.
**Template** (adapt the section names to the actual changeset):
```markdown
<One-line intro: what this release is about.>
> [!IMPORTANT]
> **<Heads-up title>.** <The one thing to know before upgrading. Omit the callout if there's nothing.>
## <Theme A, e.g. Lockfile compatibility>
<Optional one-line lead.>
| Area | What changed | Commit |
| --- | --- | --- |
| <area> | <what changed, one clause> | [`<sha7>`](https://github.com/nubjs/nub/commit/<full-sha>) |
## <Theme B, e.g. Performance>
<Short blurb with the PR link inline.> ([#17](https://github.com/nubjs/nub/pull/17))
## Testing & internals
- <Bullet> ([`<sha7>`](https://github.com/nubjs/nub/commit/<full-sha>)).
---
## What's Changed
<!-- appended verbatim from `gh api …/releases/generate-notes` — the PR list, New Contributors, and Full Changelog link -->
* <PR title> by @<author> in https://github.com/nubjs/nub/pull/<n>
**Full Changelog**: https://github.com/nubjs/nub/compare/<PREV>...v<ver>
```
Generate the bottom `## What's Changed` breakdown mechanically so every merged PR is listed:
```bash
# PR-level list + New Contributors + Full Changelog compare link — append verbatim below the curated narrative
gh api repos/nubjs/nub/releases/generate-notes \
-f tag_name=v<ver> -f previous_tag_name=$PREV --jq '.body'
```
Append that block under a `---` separator below the curated sections, then `gh release edit`. The curated narrative stays on top; this exhaustive PR list goes underneath.
Update the release body:
```bash
# Edit a notes file, then:
gh release edit v<ver> --notes-file <path-to-notes.md>
gh release view v<ver> --repo nubjs/nub --json body -q .body # verify it rendered
```
The v0.1.4 and v0.1.3 release bodies are the reference exemplars of this structure.
## Step 4b — Publish the notes as a blog post (MANDATORY — every release)
Every release also ships as a blog post under `site/content/blog/`. This is a standard release step, done on every version — the same content/presentation-to-`main` exception as docs (commit directly to `main`, no PR). Before writing, invoke the `prose-writing` skill and follow its guide (blog copy: routine patch notes stay factual, neutral, unsigned, scannable — no hype, no personality; a milestone version gets a fuller treatment but the same neutral bar).
- **File:** `site/content/blog/nub-<major>-<minor>-<patch>.mdx` (e.g. `nub-0-2-10.mdx`) — the filename is the URL slug; fumadocs auto-globs `content/blog/*.mdx`, so no index/meta wiring is needed.
- **Frontmatter** (schema from `source.config.ts`, all four required): `title: "Nub <ver>"` (add a `: <theme>` subtitle only for a milestone), `description:` a plain sentence with **no inline code/backticks** (the field renders raw), `author: The Nub Team`, `date: <YYYY-MM-DD>` **back-dated to the release's `publishedAt`** so the timeline stays chronological.
- **Body:** a short lede, then the release's themed sections adapted to blog prose — not a raw changelog dump. Carry over the callouts and per-theme tables. Close with `The [full release notes](https://github.com/nubjs/nub/releases/tag/v<ver>) list every change in this release.`
- **Structure a feature-carrying release around its features:** one top-level `##` per major new feature, then `## Breaking changes`, then `## Bug fixes`. A batch of independent fixes goes in a table whose FIRST column is the PR link — `.blog-prose td:not(:last-child)` is `width:1%` + `nowrap` by design, so a prose column anywhere but last blows the table past the 720px article column.
عرض على GitHub