sequant-io

release

"Automates the full release workflow: version bump, git tag, GitHub release, and npm publish."

sequant-io 1 1 Updated 2w ago
GitHub

Install

npx skillscat add sequant-io/sequant/release

Install via the SkillsCat registry.

SKILL.md

Local overrides (read this first). Before following any instruction below, check whether .claude/.local/skills/release/overrides.md exists. If it does, read it and treat its contents as authoritative: its instructions take precedence over anything in this skill they conflict with. This is the supported way to tailor /release without forking it — overrides.md lives under .claude/.local/, which sequant update and sync never overwrite.

Release Skill

Automates the full release workflow: version bump, git tag, GitHub release, and npm publish.

Usage

/release [patch|minor|major] [--prerelease <tag>] [--soaked] [--dry-run]
  • /release - Interactive, asks for version type
  • /release patch - Patch release (1.3.1 → 1.3.2)
  • /release minor - Minor release (1.3.1 → 1.4.0)
  • /release major - Major release (1.3.1 → 2.0.0)
  • /release minor --prerelease beta - Pre-release (1.3.1 → 1.4.0-beta.0)
  • /release --dry-run - Preview without publishing
  • /release --soaked - Promote an already-published next version to latest (Step 9b); refused without this flag

Pre-flight Checks

Run ALL checks before proceeding. STOP if any fails.

Git Checks

# 1. On main branch
[ "$(git branch --show-current)" = "main" ] || { echo "Not on main"; exit 1; }

# 2. Clean working tree
[ -z "$(git status --porcelain)" ] || { echo "Uncommitted changes"; exit 1; }

# 3. In sync with remote
git fetch origin
[ -z "$(git log HEAD..origin/main)" ] || { echo "Behind origin - pull first"; exit 1; }

# 4. The latest CI `push` run on main is green (red-main rule, below).
#    Filter to the CI workflow: without --workflow the newest push run can be
#    Scorecard or another workflow, and a red CI would read as green.
ci_run=$(gh run list --branch main --event push --workflow ci.yml --limit 1 \
  --json conclusion,url --jq '.[0] | "\(.conclusion) \(.url)"')
case "$ci_run" in success*) ;; *) echo "Red main: latest CI push run is '$ci_run' - revert first (#1093)"; exit 1;; esac

Red main — revert first, debug second (#1093). A failing push run on main
is reverted first and debugged second: the next change that lands is the revert
of the commit that broke it, not a forward fix and not an investigation branch,
and the revert PR references the failing run by URL. Do not release off a red
main, and do not release a forward fix "that will also make it green" — revert,
let CI go green, then release. Investigate afterwards on a branch off a green
main. This mirrors the rule in CLAUDE.md § Red main.

Quality Checks

# 4. Tests pass
npm test

# 5. Build works
npm run build

# 6. No security vulnerabilities (high/critical)
npm audit --audit-level=high || echo "Warning: audit issues found"

npm Checks

# 7. Logged into npm
npm whoami || { echo "Not logged in - run: npm login"; exit 1; }

# 8. Version doesn't already exist
pkg_name=$(node -p "require('./package.json').name")
current=$(node -p "require('./package.json').version")
npm view "${pkg_name}@${current}" version 2>/dev/null && { echo "Version already published"; exit 1; }

# 9. Preview package contents
npm pack --dry-run 2>&1 | tail -20

GitHub Checks

# 10. Authenticated with GitHub
gh auth status || { echo "Not logged in - run: gh auth login"; exit 1; }

Plugin Checks

# 11. Plugin manifest is valid (#1135). Name each manifest explicitly:
#     `claude plugin validate .` checks only marketplace.json and skips plugin.json.
#     Warnings pass (non-strict); errors, or no `claude` on PATH, stop the release.
claude plugin validate plugin/.claude-plugin/plugin.json || { echo "Plugin manifest invalid - fix plugin/.claude-plugin/plugin.json"; exit 1; }

# 12. Marketplace manifest is valid
claude plugin validate .claude-plugin/marketplace.json || { echo "Marketplace manifest invalid - fix .claude-plugin/marketplace.json"; exit 1; }

Documentation Checks

Note: Documentation freshness checks that compare against the version being released run after Step 4 (the version bump), not here in pre-flight:

  • docs/internal/what-weve-built.md title + ASCII version stamps → Step 4.63
  • CHANGELOG.md freshness (version entry present) → Step 4.65
  • README.md "What's new in " heading freshness → Step 4.66

They must compare against the new version, which doesn't exist in package.json until Step 4 runs. Running them in pre-flight would compare against the previous release — which the docs already reflect — so the gate would never fire for the release in progress (root cause class of #684 / #687).

Release Steps

Step 0: Acquire the Checkout Lock (REQUIRED)

Everything from here on mutates the main checkout: the version bump and its
commit, tag, and push (Steps 4–7), plus the rollback verbs (git reset --soft,
git checkout -- …) if a step fails. git commit/tag/push are not guarded
by pre-tool.sh, but the git reset in rollback is — and, more importantly,
holding the checkout lock (#901) is what stops a session on another issue from
interleaving a git checkout between the bump and the commit and landing the
release commit on the wrong HEAD. So claim the tree now, before the first
interactive approval pause (Step 2's release-notes review), exactly as
/fullsolve does in its Phase 0.3.

/release has no issue of its own, and the checkout lock proves ownership by a
positive integer — the pre-tool.sh guard and sequant locks checkout both
key on --issue=<N>. So /release claims the tree under a reserved sentinel
issue id, 999999999 (a number that will never be a real issue), and uses it
on the acquire and on every release below. locks list and the CLI's
release-refusal render this sentinel as /release (sentinel) so it does not
read as a real issue. A symbolic holder (e.g. --label=release) would avoid
the reserved-number scheme entirely, but it would require changing the
lock-file schema, the CLI, and the numeric-only pre-tool.sh guard — out of
scope for #911 (tracked there as a follow-up).

# Claim the shared working tree before the first mutation / approval pause.
# Skip for --dry-run: it previews only and mutates nothing, so it needs no lock.
npx sequant locks checkout acquire \
    --issue=999999999 \
    --command="/release" \
    --skip-pid-check || true
export SEQUANT_ISSUE=999999999

Acquire after the read-only pre-flight checks above, not before: those checks
(git status/fetch, npm test/build, npm whoami, gh auth) mutate
nothing, so a pre-flight abort holds no lock and needs no release. That is why the
pre-flight exit 1 branches carry no release call — you cannot release a lock you
have not taken.

--issue=999999999 is mandatory on release (#906): it is what proves you are
the holder once the acquiring shell's PID is gone (--skip-pid-check). Stale
recovery is age-based only (the 6h SEQUANT_SKILL_LOCK_TTL_MS and the 24h
SEQUANT_MAX_LOCK_AGE_MS ceiling), not same-host dead-PID recovery.

Release contract: release the checkout lock —
npx sequant locks checkout release --issue=999999999 || true — on every
path that exits after this acquire: happy-path completion (Post-Release
Verification), any mid-release error (Error Handling), and the Rollback
Procedures. Releasing an already-released lock is a harmless no-op, so a failure
that both errors and rolls back may run two of these — that is safe.

Orchestrator/MCP mode: when SEQUANT_ORCHESTRATOR is set, every locks checkout action is a no-op (exit 0, no file touched) and the pre-tool.sh
checkout guard stands down, so the acquire/release calls are safe to run
unconditionally.

Step 1: Determine Version

If version type not provided as argument, ask the user:

Type When to Use Example
patch Bug fixes, maintenance, no new features 1.3.1 → 1.3.2
minor New features, backwards compatible 1.3.1 → 1.4.0
major Breaking changes 1.3.1 → 2.0.0
prerelease Alpha/beta/RC versions 1.3.1 → 1.4.0-beta.0
current=$(node -p "require('./package.json').version")
echo "Current version: ${current}"

Step 2: Generate Release Notes

Gather commits since last tag:

last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$last_tag" ]; then
  git log ${last_tag}..HEAD --oneline --no-merges
fi

Categorize by conventional commit prefix:

  • feat: → Features
  • fix: → Bug Fixes
  • perf: → Performance
  • docs: → Documentation
  • refactor: → Refactoring
  • chore: → Maintenance
  • BREAKING CHANGE: → Breaking Changes (for major releases)

Release notes template:

## What's Changed

### Features
- Feature description (#PR)

### Bug Fixes
- Fix description (#PR)

### Breaking Changes
- Description of breaking change and migration path

**Full Changelog**: https://github.com/sequant-io/sequant/compare/v{old}...v{new}

Show draft to user for approval before proceeding.

Step 3: Update CHANGELOG.md (if exists)

If CHANGELOG.md exists, use the Edit tool to:

  1. Replace ## [Unreleased] with ## [{new_version}] - {YYYY-MM-DD}
  2. Insert a fresh ## [Unreleased] section above the newly stamped version:
## [Unreleased]

## [1.4.0] - 2026-01-10

### Added
- New feature X
...

This ensures the next development cycle has an [Unreleased] section ready for contributors.

Step 4: Bump Version

# For regular releases
npm version {patch|minor|major} --no-git-tag-version

# For pre-releases
npm version prerelease --preid=beta --no-git-tag-version

Step 4.5: Sync Plugin Version

IMPORTANT: Keep plugin.json in sync with package.json.

Use the Read tool to read plugin/.claude-plugin/plugin.json, then use the Edit tool to update the version field:

Read(file_path="plugin/.claude-plugin/plugin.json")

Edit(file_path="plugin/.claude-plugin/plugin.json",
     old_string="\"version\": \"<old_version>\"",
     new_string="\"version\": \"<new_version>\"")

If plugin/.claude-plugin/plugin.json does not exist, skip this step.

Why sync? Sequant is distributed as both npm package and Claude Code plugin. Both must have matching versions to avoid user confusion and ensure compatibility.

Step 4.6: Update what-weve-built.md

IMPORTANT: Keep docs/internal/what-weve-built.md version references in sync and document new features.

Get the new version, then use the Edit tool to update the file:

new_version=$(node -p "require('./package.json').version")

Then use the Edit tool (not sed) to update docs/internal/what-weve-built.md:

# Update title version - use Edit tool with replace_all=true
Edit(file_path="docs/internal/what-weve-built.md",
     old_string="Sequant v<old_version>",
     new_string="Sequant v${new_version}",
     replace_all=true)

# Update ASCII art version - use Edit tool with replace_all=true
Edit(file_path="docs/internal/what-weve-built.md",
     old_string="SEQUANT v<old_version>",
     new_string="SEQUANT v${new_version}",
     replace_all=true)

# Update "Current Version" line - use Edit tool
Edit(file_path="docs/internal/what-weve-built.md",
     old_string="**Current Version:** <old_version>",
     new_string="**Current Version:** ${new_version}")

Auto-generate feature bullets from CHANGELOG:

Extract features from the [Unreleased] section of CHANGELOG.md:

# Extract [Unreleased] entries from CHANGELOG.md
if [ -f "CHANGELOG.md" ]; then
  # Get content between [Unreleased] and next version header
  unreleased_content=$(sed -n '/^## \[Unreleased\]/,/^## \[/p' CHANGELOG.md | head -n -1)

  # Extract Added entries (these become what-weve-built features)
  added_entries=$(echo "$unreleased_content" | sed -n '/^### Added/,/^### /p' | grep -E '^\s*-' | head -n -1 || true)

  if [ -n "$added_entries" ]; then
    echo "Features to add to what-weve-built.md:"
    echo "$added_entries"
  else
    echo "No new features in [Unreleased] section"
  fi
fi

If CHANGELOG has features, use the Edit tool to update docs/internal/what-weve-built.md:

  1. Update the "Recent Additions" section header:

    Edit(file_path="docs/internal/what-weve-built.md",
         old_string="### Recent Additions (v<old_version>)",
         new_string="### Recent Additions (v${new_version})")
  2. Add feature bullets under "Recent Additions":
    Transform CHANGELOG entries to what-weve-built format:

    CHANGELOG Format what-weve-built Format
    - Feature description (#123) - **Feature Name** - Brief description
    - Multi-line feature (#123)\n - Sub-detail - **Feature Name** - Brief description

    Example transformation:

    # From CHANGELOG:
    - CHANGELOG update step in /exec skill (#320)
      - Instructs /exec to add [Unreleased] entries during feature commits
    
    # To what-weve-built:
    - **CHANGELOG Automation** - Automatic CHANGELOG entry requirements in /exec and /qa
  3. Recount the "At a Glance" table on every minor release — incrementing by hand let it drift to a third of the real numbers (it listed 57 test files when there were 343). Run after npm run build, write the results into the table, and update its "Counted …" line:

    echo "Slash Commands:        $(ls -d templates/skills/*/ | grep -vc _shared)"
    echo "CLI Commands:          $(node dist/bin/cli.js --help | awk '/^Commands:/{f=1;next} f && /^  [a-z]/ && $(1) == "help" {next} f && /^  [a-z]/ {n++} END{print n}')"
    echo "Core Library Modules:  $(git ls-files 'src/lib/*.ts' 'src/lib/**/*.ts' | grep -vE '\.test\.ts$|__tests__|__fixtures__|\.d\.ts$' | wc -l | tr -d ' ')"
    echo "Test Files:            $(git ls-files '*.test.ts' '*.test.tsx' | wc -l | tr -d ' ')"
    echo "Documentation Files:   $(git ls-files 'docs/*.md' 'docs/**/*.md' | grep -v '^docs/internal/' | wc -l | tr -d ' ')"
    echo "Stack Configurations:  $(node --input-type=module -e 'import("./dist/src/lib/stacks.js").then(m=>console.log(Object.keys(m.STACKS).length))')"
    echo "Lines of TypeScript:   $(git ls-files 'src/*.ts' 'src/**/*.ts' 'bin/*.ts' | grep -vE '\.test\.ts$|__tests__|__fixtures__' | xargs cat | wc -l | tr -d ' ')"

Fallback to commit-based detection:

If CHANGELOG.md doesn't exist or has no [Unreleased] section, fall back to commit-based detection:

last_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$last_tag" ]; then
  echo "New features since ${last_tag}:"
  git log ${last_tag}..HEAD --oneline --no-merges | grep -E "^[a-f0-9]+ feat" || echo "  (none)"
fi

Ask the user to review what-weve-built.md before proceeding if there are new features to document.

Step 4.63: Verify what-weve-built.md Version Stamps

IMPORTANT: This runs after Step 4 (version bump) and Step 4.6 (which updates the stamps), so package.json already holds the new version. Running it earlier (in pre-flight) would compare against the previous release — which the file already reflects — so the gate would never fire for the version actually being released (root cause class of #684 / #687, the same ordering bug fixed for the README check in #685).

Warn-only (like the CHANGELOG freshness check): prompt the releaser if the title or ASCII-art version stamp omits/mismatches the version being released.

# Warn if docs/internal/what-weve-built.md version stamps don't match the version being released
if [ -f "docs/internal/what-weve-built.md" ]; then
  new_version=$(node -p "require('./package.json').version")

  # Check title version
  title_version=$(grep -oE "Sequant v[0-9]+\.[0-9]+\.[0-9]+" docs/internal/what-weve-built.md | head -1 | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" || true)
  if [ "$title_version" != "$new_version" ]; then
    echo "Warning: docs/internal/what-weve-built.md title shows v${title_version}, expected v${new_version}"
  fi

  # Check ASCII art version
  ascii_version=$(grep -E "SEQUANT v[0-9]+\.[0-9]+\.[0-9]+" docs/internal/what-weve-built.md | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" || true)
  if [ "$ascii_version" != "$new_version" ]; then
    echo "Warning: docs/internal/what-weve-built.md ASCII art shows v${ascii_version}, expected v${new_version}"
  fi
fi

Step 4.65: Verify CHANGELOG Freshness

IMPORTANT: This runs after Step 4 (version bump) so package.json already holds the new version. Running it earlier (in pre-flight) would compare against the previous release, which the CHANGELOG already lists — so the gate would never fire for the version actually being released (root cause class of #684).

Note (#701): #694 moved the changelog wall out of README.md into CHANGELOG.md and removed the README "What's new" section, so this gate now checks CHANGELOG.md (the source of truth). Grepping README.md for a now-absent # What's new heading made the warn-only gate fire on every release.

Warn-only (like the what-weve-built checks): prompt the releaser to add a CHANGELOG.md entry if the new version is absent.

# Warn if CHANGELOG.md omits the version being released
if [ -f "CHANGELOG.md" ]; then
  new_version=$(node -p "require('./package.json').version")

  # Match the Keep-a-Changelog heading for this version, e.g. "## [2.4.0] - 2026-05-30"
  if ! grep -qF "[${new_version}]" CHANGELOG.md; then
    echo "Warning: CHANGELOG.md omits v${new_version} — add a \"## [${new_version}]\" entry before releasing"
  fi
fi

Step 4.66: Verify README "What's new" Freshness

IMPORTANT: This runs after Step 4 (version bump) so package.json already holds the new version. Running it earlier (in pre-flight) would compare against the previous release — which the README already reflects — so the gate would never fire for the version actually being released (root cause class of #684 / #687, the same ordering bug fixed for the README check in #685).

Note (#702 / #707): This gate owns the hand-written ### What's new in <major>.<minor> positioning section that #702 re-added to README.md — distinct from the changelog wall that #694 moved to CHANGELOG.md (covered by Step 4.65). It keys on the minor line, not a full semver: "What's new in 2.6" is correct for the entire 2.6.x line, so a patch release (2.6.0 → 2.6.1) does NOT fire against a matching heading. During v2.6.0 the heading still read "What's new in 2.5" and shipped stale (fixed manually in 18c2d42); this gate flags that case.

Warn-only (like the CHANGELOG freshness check): prompt the releaser if the README's latest "What's new" heading lags the minor line being released. No README edit is auto-applied — the section's content and positioning are an editorial decision.

# Warn if README.md's latest "What's new" heading lags the minor line being released.
# Keys on the MINOR line (e.g. "2.6"), so a patch release against a matching heading stays silent.
if [ -f "README.md" ]; then
  new_version=$(node -p "require('./package.json').version")
  new_minor=$(echo "$new_version" | grep -oE '^[0-9]+\.[0-9]+')

  # Anchor to a markdown heading (^#+) so a "What's new in X.Y" mention in prose can't be read as
  # the section, and take the numerically-highest minor (sort -t. then tail -1) so the result is
  # independent of heading order in the file rather than assuming newest-first.
  readme_minor=$(grep -oE "^#+[[:space:]]+What's new in [0-9]+\.[0-9]+" README.md \
    | grep -oE '[0-9]+\.[0-9]+' | sort -t. -k1,1n -k2,2n | tail -1 || true)

  # The -n guard is load-bearing: if the section is ever removed again (as #694 did),
  # this gate stays silent rather than firing on every release (the bug #701 fixed for the old gate).
  if [ -n "$readme_minor" ] && [ "$readme_minor" != "$new_minor" ]; then
    echo "Warning: README.md 'What's new' section is for $readme_minor, expected $new_minor — add/update the heading before releasing"
  fi
fi

Step 4.67: Update SECURITY.md Supported Versions

IMPORTANT: Runs after Step 4 so package.json holds the new version.

SECURITY.md states that only the latest minor series receives security fixes and lists it in the ## Supported versions table. That row is typed, so every minor release must move it — otherwise the public policy points at the previous line (QA on #980 flagged exactly this rot).

Use the Edit tool on SECURITY.md: in the table under ## Supported versions, replace the supported row's minor (e.g. 2.14.x) with the new minor (e.g. 2.15.x) and the < 2.14 row with < 2.15. On a patch release the minor is unchanged and this step is a no-op.

# Verify: the supported row names the minor line being released.
new_minor=$(node -p "require('./package.json').version" | grep -oE '^[0-9]+\.[0-9]+')
if [ -f SECURITY.md ] && ! grep -qE "^\| ${new_minor}\.x \|" SECURITY.md; then
  echo "SECURITY.md supported-versions table does not list ${new_minor}.x — update it before releasing"
fi

Step 4.7: Regenerate Marketplace Artifact

IMPORTANT: The marketplace plugin artifact under dist/marketplace/ is bundled into the published tgz via files: ["dist", ...] in package.json — even though dist/ is gitignored. Regenerate it from current sources before packing/publishing so the bundled README always matches; otherwise a stale artifact ships (root cause of #684 — the v2.4.0 tgz carried a "Node.js 20+" README after the floor moved to 22.12).

This is an action step, so it lives in Release Steps (not the read-only pre-flight checks). It MUST run before Step 5 (npm pack) and Step 9 (npm publish).

# Regenerate the marketplace plugin artifact from current sources
npm run prepare:marketplace

# Freshness sanity check: generated README's Node.js floor should match package.json engines.
# Warn-only (regeneration above already fixes the artifact; this is a belt-and-suspenders signal).
gen_readme="dist/marketplace/external_plugins/sequant/README.md"
if [ -f "$gen_readme" ]; then
  engines_floor=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g,'')")  # e.g. 22.13.0
  readme_floor=$(grep -oE "Node\.js [0-9]+\.[0-9]+" "$gen_readme" | head -1 | grep -oE "[0-9]+\.[0-9]+" || true)
  if [ -n "$readme_floor" ] && [ "${engines_floor%.*}" != "$readme_floor" ]; then
    echo "Warning: generated marketplace README shows Node.js ${readme_floor}, package.json engines floor is ${engines_floor}"
  fi
fi

Step 5: Verify Package Size

# Check what will be published
npm pack --dry-run

# Verify size is reasonable (warn if > 500KB)
size=$(npm pack --dry-run 2>&1 | grep "total files" -A1 | tail -1 || true)
echo "Package size: ${size}"

Step 6: Commit and Land Through a PR

main is protected by a ruleset (#1109): it requires the test and canary
checks, strict up-to-date, and rejects every direct push with GH013. The
release commit lands the same way every other commit does — through a PR that
passes the same checks (#1131). Do not push main; do not add a bypass.

new_version=$(node -p "require('./package.json').version")
git switch -c "chore/release-v${new_version}"
git add package.json package-lock.json CHANGELOG.md plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json docs/internal/what-weve-built.md SECURITY.md README.md
# The shipped MCP config is gitignored-but-tracked; Step 4.7 re-pinned it to this version (#988), so force-add it into the release commit.
git add -f .mcp.json
git commit -m "chore: release v${new_version}"
git push -u origin "chore/release-v${new_version}"
gh pr create --base main --head "chore/release-v${new_version}" \
  --title "chore: release v${new_version}" \
  --body "Release v${new_version}. Lands through a PR because the main ruleset rejects direct pushes (#1109, #1131)."

Wait for test and canary on the PR head (poll commits/<sha>/check-runs,
not gh pr checks --watch, which can report the previous head), then:

gh pr merge --squash --delete-branch --subject "chore: release v${new_version}"
git switch main && git pull --ff-only origin main

Step 7 tags the squash commit on main; tags are outside the branch rule.

Step 7: Create and Push Tag

git tag -a "v${new_version}" -m "Release v${new_version}"
git push origin "v${new_version}"

Step 8: Create GitHub Release

# Regular release
gh release create "v${new_version}" \
  --title "v${new_version} - {title}" \
  --notes "{release_notes}"

# Pre-release (alpha/beta/rc)
gh release create "v${new_version}" \
  --title "v${new_version}" \
  --notes "{release_notes}" \
  --prerelease

For the title, use a short summary:

  • v1.4.0 - New Feature Name
  • v1.3.2 - Bug Fix Release
  • v2.0.0 - Breaking Changes

Step 9: Publish to npm

A regular release publishes to the next dist-tag, never straight to latest (#1098). latest moves only in Step 9b, after the canary job has passed against the published tarball and a manual soak. Downstream projects run npx sequant@latest, so a defect that only shows up in a real install must be found on next first.

# Regular release: `next` first
npm publish --tag next

# Pre-release with its own tag (also never becomes "latest")
npm publish --tag beta

If npm returns EOTP (2FA required):

Non-interactive environments cannot handle the OTP prompt. Hand the user this exact
line. package.json sets "publishConfig": { "tag": "next" } (#1184), so a bare
npm publish also lands on next — before that, a bare publish moved latest
immediately, on both 2.17.0 and 2.18.0. Keep --tag next in the handoff line anyway:
it is explicit and harmless. An explicit --tag still overrides publishConfig
(pre-releases use --tag beta). With npm web auth the --otp part may be omitted:

npm publish --tag next --otp=<code>

When the user reports done, verify before continuing: npm view sequant dist-tags
must show next: <new_version> and an unchanged latest. If latest moved, run the
Step 9b soak checklist immediately and keep npm dist-tag add sequant@<previous> latest
ready as the rollback.

A publish that npm accepted can take several minutes to appear: the registry answers
PUT … 202 ("being processed"), and until then npm view sequant@<new_version> returns
404 and dist-tags looks unchanged. That is lag, not failure (seen on 2.18.0). Before
concluding the publish failed, read the newest ~/.npm/_logs/*-debug-0.log: find the
argv (was it a bare npm publish?), the Publishing to … with tag … line, and the
PUT status. Then poll the registry directly, bypassing npm's cache:
curl -s https://registry.npmjs.org/sequant | jq '."dist-tags"'.

Do NOT attempt to pass OTP codes programmatically or retry npm publish in a loop. Hand off to the user and continue with post-release verification once they confirm.

Step 9b: Promote next to latest (requires --soaked)

Print this soak checklist, then stop unless --soaked was passed:

Soak checklist for {new_version} on the next tag
  [ ] CI `canary` job green against the published tarball
  [ ] npx sequant@next sync --dry-run, run in each local repo of the maintainer's own projects, lists only expected decisions
  [ ] npx sequant@next doctor exits 0 in those repos

Without --soaked: refuse. Do not run npm dist-tag add ... latest. Report "Published to next; latest unchanged. Re-run /release --soaked after the soak checklist passes." and finish. Never infer --soaked from context or pass it yourself.

With --soaked:

npm dist-tag add sequant@${new_version} latest

If npm returns EOTP, hand off to the user with npm dist-tag add sequant@${new_version} latest --otp=<code>; same no-retry rule as Step 9.

Post-Release Verification

Verify both platforms show the new version:

# npm verification — a 202 publish can take several minutes to appear (see Step 9);
# poll the registry directly rather than trusting one `npm view`
curl -s https://registry.npmjs.org/sequant | jq '."dist-tags"'   # next = new version; latest only after Step 9b

# GitHub verification
gh release view "v${new_version}"

# Test install
npx sequant@${new_version} --version

The release is complete — hand the working tree back (#901). This is the
counterpart to the acquire in Step 0.

npx sequant locks checkout release --issue=999999999 || true

Output Summary

Release v{version} Complete

  Version:  {old} → {new}
  Commit:   {hash}
  Tag:      v{new}

  GitHub:   https://github.com/sequant-io/sequant/releases/tag/v{new}
  npm:      https://www.npmjs.com/package/sequant/v/{new}
  Plugin:   Version synced in plugin/.claude-plugin/plugin.json
  Docs:     Version synced in docs/internal/what-weve-built.md

  Install (npm):
    npm install sequant@{new}
    npx sequant@{new}

  Install (plugin):
    /plugin marketplace update sequant-io/sequant   # refreshes the marketplace LISTING only
    /plugin install sequant                         # new installs

  Update an existing plugin install:
    claude plugin update sequant@sequant            # then restart Claude Code

Verification:
  [x] npm view shows correct version
  [x] GitHub release created
  [x] Tag pushed
  [x] plugin.json version synced
  [x] what-weve-built.md version synced
  [x] New features documented (if any)

Next steps:
  - Announce release (if major/minor)
  - Update dependent projects
  - Monitor for issues
  - Installed plugins do NOT pick up this release automatically: Claude Code
    pins each install at its installed version, and `/plugin marketplace
    update` refreshes only the marketplace listing. Each existing install
    updates only via `claude plugin update sequant@sequant` + restart
    (the pre-tool hook nags stale installs once a day — #784)

Dry Run Mode

When --dry-run is specified:

  1. Run all pre-flight checks
  2. Show what version would be created
  3. Show draft release notes
  4. Show npm pack --dry-run output
  5. Do NOT execute any publish commands
Dry Run Summary

  Would release: 1.3.1 → 1.4.0

  Package contents:
    - 176 files
    - 195.9 kB packed

  Release notes:
    [draft notes here]

  To execute: /release minor

Rollback Procedures

Before npm publish

# Undo version bump (if not committed)
git checkout -- package.json package-lock.json

# Undo commit (if not pushed)
git reset --soft HEAD~1

# Delete local tag
git tag -d v{version}

# Rollback aborts the release — hand the working tree back (#901/#906).
# The git reset above is a guarded verb; it runs unrefused only because Step 0
# made this session the checkout holder.
npx sequant locks checkout release --issue=999999999 || true

After git push, before npm publish

# Delete remote tag
git push origin :refs/tags/v{version}

# Delete GitHub release
gh release delete v{version} --yes

# Revert commit
git revert HEAD
git push origin main

# Rollback aborts the release — hand the working tree back (#901/#906).
npx sequant locks checkout release --issue=999999999 || true

After npm publish

Do NOT use npm unpublish - it breaks dependent projects.

Instead:

  1. Publish a patch fix: npm version patch && npm publish
  2. Deprecate bad version: npm deprecate sequant@{bad} "Use {good} instead"

Error Handling

If a release step fails and you halt without completing, release the checkout
lock first
(#901/#906) — Step 0 acquired it and every abort after that must hand
the working tree back before stopping:

npx sequant locks checkout release --issue=999999999 || true
Error Cause Resolution
"Not on main" Wrong branch git checkout main
"Uncommitted changes" Dirty working tree git stash or commit
"Behind origin" Remote has new commits git pull origin main
"Tests failed" Failing tests Fix tests first
"Version exists" Already published Choose different version
"Not logged in (npm)" npm auth expired npm login
"Not logged in (gh)" GitHub auth expired gh auth login
"OTP required" npm 2FA enabled Enter code when prompted
"Package too large" Bloated package Check .npmignore

Best Practices

Versioning

  • Follow Semantic Versioning
  • Use pre-release tags for testing: 1.4.0-beta.1
  • Major version 0.x.x indicates unstable API

Release Notes

  • Write for users, not developers
  • Highlight breaking changes prominently
  • Include migration guides for major releases
  • Link to relevant issues/PRs

npm

  • Keep package size small (< 500KB ideal)
  • Use files in package.json or .npmignore
  • Test with npm pack before publishing
  • Use --tag for pre-releases to avoid becoming "latest"

GitHub

  • Use pre-release flag for beta/RC versions
  • Attach relevant assets (if any)
  • Use consistent title format

Security

  • Run npm audit before releasing
  • Don't include sensitive files (check npm pack --dry-run)
  • Verify npm whoami shows correct account

Examples

Standard Patch Release

/release patch

Minor Release with Custom Notes

/release minor
# Review generated notes
# Edit if needed
# Approve to publish

Beta Pre-release

/release minor --prerelease beta

Creates: 1.4.0-beta.0

Preview Without Publishing

/release minor --dry-run