name: release-orchestrator description: Use when releasing @haklex/* packages and propagating to downstream consumers (Yohaku, mx-core/apps/admin, mx-core/apps/core, mx-space). Owns end-to-end orchestration: change detection, per-package semver calc, peer-dep audit to prevent duplicate-instance bugs (e.g. lucide-react React Context mismatch), topologically-ordered publish with npm registry polling, parallel-worktree downstream smoke tests, auto-revert on failure, and direct push to downstream primary branches (no PRs). Fully autonomous — no user confirmation gates. Supersedes the old /release command. user_invocable: true
Release Orchestrator
Owns the full multi-package release pipeline. Supersedes the old .claude/commands/release.md.
Invocation contract
Do not require caller-supplied release metadata. Infer the release context from repository state:
- Changeset summary — derive from
git log --oneline "$LAST"..HEADandgit diff --stat "$LAST"..HEAD. - Affected packages — derive mechanically from
git diff --name-only "$LAST"..HEAD; do not ask the caller for a package list. - Release mode — see Phase 0.5; defaults to
incremental(publish the workspace closure of changed packages — Phase 4 expandsCHANGED_PKGStoPUBLISH_SETvia forward closure over workspace cross-deps), upgradeable tofulleither by user opt-in keyword or by Phase 2 auto-fallback on amajorbump.
If no package has publishable src/** changes, stop and report that there is no releasable package diff.
Repo layout
| Concern | Path / Rule |
|---|---|
| haklex root | Run the release from the same worktree where git status is clean. Use git worktree list to confirm. Never switch checkouts mid-release. |
| Published namespace | @haklex/*, via pnpm run publish:packages (excludes @haklex/rich-editor-demo). |
| CLI package | @haklex/rich-litexml-cli ships the litexml binary. It depends on the published dist assets of @haklex/rich-compose (dist/style.css, dist/litexml-html-preview-client.js) at runtime via require.resolve, so the compose build must succeed before the CLI is published. Also one of the few packages whose installed binary should be sanity-checked post-publish — see Phase 4.5. |
| Version strategy | Shared — every @haklex/* lives on the same version (read from packages/rich-editor/package.json). |
| Downstream: Yohaku | /Users/innei/git/innei-repo/Yohaku/apps/web/package.json — actual pin set varies; derive via grep -lrn '"@haklex/' --include=package.json at release time. Worktree quirk: Yohaku/packages/design-system is a symlink to the sibling directory ../design-oss/design-system. git worktree add does not materialize sibling-directory targets — pnpm install will fail with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @yohaku/design-system. Workaround: cp -R /Users/innei/git/innei-repo/Yohaku/design-oss/ /tmp/release-Yohaku-$V/design-oss/ after worktree add and before pnpm install, then rm -rf /tmp/release-Yohaku-$V/design-oss/.git so the symlink resolves locally. |
| Downstream: mx-core | Four manifests in one repo: apps/admin/package.json (React dashboard — broad @haklex/* pin set), apps/core/package.json (rich-headless only), packages/cli/package.json (rich-headless, rich-litexml, rich-litexml-cli), and packages/editor/package.json (rich-headless, rich-litexml). Always grep all four — grep -lrn '"@haklex/' /Users/innei/git/innei-repo/mx-core/ --include=package.json | grep -v node_modules — and rewrite pins in every hit, not just apps/*. Missing the packages/* pins produces duplicate-version installs (TS2322 across LitexmlRegistry heap boundary on first downstream typecheck). admin-vue3 is retired — do not touch it. |
| mx-space | Same repo as mx-core (mx-core is mx-space/core). |
| Deleted packages | @haklex/rich-static-renderer was retired. If a downstream's tracked branch still lists it, drop it entirely instead of bumping (re-pinning a deleted package will 404 at install). |
Phase 0.5 — Mode detection
Two release modes:
- incremental (default) — publish the workspace closure of packages whose
src/**changed since$LAST.CHANGED_PKGS(from Phase 1) seeds the closure; Phase 4 walks workspace cross-deps + peerDeps until fixed point to producePUBLISH_SET. Required because this repo usesworkspace:*(pnpm publishes as exact version) — see Phase 2. - full — publish every
@haklex/*package (excluding@haklex/rich-editor-demo), regardless of diff. Matches the legacy "publish everything" behaviour.
Default to incremental. Promote to full only via one of:
- User opt-in keyword — case-insensitive match for
full,全量, or standaloneallin the user's invocation text or skill args. - Phase 2 auto-fallback — any changed package classifies as
major(see Phase 2 for rationale).
if grep -iqE '(^|[^a-z])(full|全量|all)([^a-z]|$)' <<< "$INVOCATION_TEXT"; then
MODE=full
else
MODE=incremental
fi
echo "Release mode: $MODE"
Print the chosen mode as a banner before Phase 1. If Phase 2 later flips the mode, print a second banner explaining the trigger (e.g. Major bump detected → switching to full). No interactive prompt — the skill remains autonomous.
Phase 1 — Pre-flight
git statusclean in haklex worktree. If dirty, stop and ask.Identify last release commit:
LAST=$(git log --grep='^release: v' -n1 --format=%H)Derive affected packages from the actual diff:
git diff --name-only "$LAST"..HEAD -- 'packages/*/src/**' 'packages/*/package.json'A package is changed only if
src/**has diffs.package.json-only or lockfile-only changes do not trigger a publish on their own. Use the full diff and commit log to infer a terse changeset summary for downstream commit messages and final reporting.Define
CHANGED_PKGSas the set of unique package names (e.g.rich-editor,rich-compose) from step 3 that havesrc/**diffs. Print a one-line tally before Phase 2 so the user can confirm scope:Changed packages: rich-editor, rich-compose, rich-ext-chat (3 of 15)CHANGED_PKGSdrives both Phase 2 (semver classification) and Phase 4 (publish set whenMODE=incremental).
Phase 2 — Semver calc (highest-wins across shared version)
Per changed package, classify its diff:
| Diff signal | Bump |
|---|---|
| Removed/renamed export, changed public function signature, changed React Context shape, removed/renamed CSS class in public theme | major |
| New export, new optional arg, new node type, new plugin, additive field, new peer | minor |
| Internal refactor, bug fix, style-only, private helper | patch |
Detect export deltas mechanically:
diff \
<(git show "$LAST":packages/ < pkg > /src/index.ts | grep -E '^export' | sort) \
<(git show HEAD:packages/ < pkg > /src/index.ts | grep -E '^export' | sort)
# '<' only = removed → major; '>' only = added → minor
Because haklex uses a shared version, compute max(bumps) across all changed packages and apply to all. Print the per-package classification table for visibility, then proceed directly to Phase 3 — do not gate on user approval, including for major classifications. Run fully autonomously.
Workspace-pinning reality (mixed workspace:* and workspace:^):
This repo writes @haklex/* cross-deps under two distinct conventions, and pnpm publishes them differently:
dependenciesandoptionalDependenciesuseworkspace:*→ pnpm publishes as exactX.Y.Z. So@haklex/rich-ext-ai-agent@0.21.0's tarball declares"@haklex/rich-litexml": "0.21.0", not^0.21.0. Forward closure (Phase 4) is required: every shared-version bump (patch / minor / major) of a package P requires every workspace package that exact-pins P in dependencies to be republished in lockstep, otherwise downstreampnpm installfails withERR_PNPM_NO_MATCHING_VERSION(v0.21.0 anchor).peerDependenciesuseworkspace:^→ pnpm publishes as^X.Y.Z. Siblings tolerate minor/patch bumps without republish. Major bumps still break the range, but Phase 2's auto-fallback flips tofullmode atmajor(below), so it does not matter.
The migration from workspace:* to workspace:^ for peerDeps landed in the v0.26.3 cycle, after a backward-closure miss caused downstream pnpm install to allocate a duplicate copy of @haklex/rich-editor (Lexical error #8 — see Real-world anchors). Until every published @haklex/* tarball has been republished at least once post-migration, some on-registry tarballs still carry the old exact peer pins. Phase 4's backward closure walk handles those transitional cases; once all @haklex/* have been republished post-migration, the backward walk becomes a no-op (no workspace:* peers remain in source).
The old ^old.x.y minor/patch reasoning is therefore right for peers, wrong for deps in this repo. Real-world failures (ERR_PNPM_NO_MATCHING_VERSION from forward miss, duplicate-instance from backward miss) both motivate the bidirectional closure algorithm in Phase 4.
MODE=incremental therefore always means closure-expanded incremental — PUBLISH_SET is computed by walking workspace cross-deps + peerDeps forward from CHANGED_PKGS until fixed point (algorithm in Phase 4). For a leaf package (e.g. a renderer that no other package imports as workspace dep), the closure equals CHANGED_PKGS and the savings vs full are real. For a deep dep (e.g. rich-editor), the closure can approach the full set — but full is still distinct: it includes packages whose closure wouldn't have reached them (e.g. the CLI), which is the right behaviour for major and for explicit user opt-in.
Major-bump auto-fallback to full mode:
If max(bumps) == major and MODE == incremental, flip MODE=full here and print:
Major bump detected → switching to full release mode.
Reason: every package's exact-pinned workspace cross-deps point at the new major.
Republishing the closure would still work, but full mode is safer at the major
boundary — it also catches packages whose closure happens not to reach them
(e.g. the CLI) but which downstream consumers still install at the new version.
minor/patch stay in closure-expanded incremental (see Phase 4) unless the user opts into full.
Phase 3 — peerDependencies audit (critical)
Reference case — commit 88bb7a0: rich-agent-chat imported lucide-react transitively via streamdown but didn't declare it as a peer. Downstream installed a second copy → two IconContext instances → silent render failure. The fix added "lucide-react": ">=1.0.0" to peerDependencies.
Rule: any library that creates a React Context MUST be a peerDependency, never a dependency.
Candidates in this repo (always-peer list): react, react-dom, lucide-react, shiki, @lexical/react, @base-ui/react, @excalidraw/excalidraw, katex.
For each changed package:
jq '.dependencies // {}, .peerDependencies // {}' packages/ < pkg > /package.json
If any always-peer candidate appears under dependencies, move it to peerDependencies with >=<currently-installed-minor> as the floor. If it's a soft dep (e.g. shiki), also add to peerDependenciesMeta.<lib>.optional = true.
@haklex/* peer specifier rule — every internal @haklex/* entry in peerDependencies MUST use workspace:^, never workspace:*. workspace:* publishes as an exact version, which causes a stale sibling tarball to force a duplicate install of any bumped peer (v0.26.3 anchor). Audit and rewrite during this phase:
jq -e '
(.peerDependencies // {})
| to_entries
| map(select(.key | startswith("@haklex/")) | select(.value == "workspace:*"))
| length == 0
' packages/ < pkg > /package.json
If any offender exists, fix it in-place before Phase 4's bump runs (otherwise bumpp writes the new exact peer pin straight into the tarball and the cycle is poisoned). dependencies and optionalDependencies keep workspace:* — those must publish as exact for the install graph to resolve.
Phase 4 — Build, publish in topological order, poll registry
Bump and build always run full — shared version means every package.json must advance in lockstep, and the workspace dep graph requires every package's dist to be fresh in case it is consumed transitively at runtime:
pnpm bumpp -r < patch | minor | major > --no-git --no-tag
pnpm run build:packages
Do not use pnpm run release:rich here — that path runs bumpp + build + publish in one step and bypasses the ordered/polled publish this skill needs. Replace it with explicit phases.
Publish set is mode-dependent. In incremental mode it is computed as the workspace-closure of CHANGED_PKGS, not CHANGED_PKGS itself (see Phase 2 — workspace-pinning reality). The closure walks both directions:
- Forward — for each package P in the set, include every
@haklex/*that P'sdependencies/peerDependencies/optionalDependenciesreference under aworkspace:specifier. PreventsERR_PNPM_NO_MATCHING_VERSIONwhen a published tarball points at an unpublished workspace dep (v0.21.0 anchor). - Backward (exact-pin only) — for each package P in the set, scan every other workspace package S. If S exact-pins P via
workspace:*in any ofdependencies/optionalDependencies/peerDependencies(all of which pnpm publishes as exactX.Y.Z), include S too. Prevents the dual failure mode where S's last published tarball still carries the old exact pin → downstream installs both new P and a duplicate old P to satisfy S's stale dep (v0.26.3 anchor —peerDependenciesdirection, Lexical error #8 from two@haklex/rich-editorcopies on disk → twolexicalruntimes → node-class mismatch; v0.26.6 anchor —dependenciesdirection,rich-compose@old/dependenciescarried exact pins to bumpedrich-renderer-image/rich-style-token, forcing a duplicate-version install across the whole renderer subtree).
Note the asymmetry: forward walk follows any workspace: prefix (*, ^, ~, explicit), because pnpm publishes them all as some concrete version and the consumer must be able to resolve them. Backward walk fires only on workspace:* pins (any of deps / optional / peer), because workspace:^ publishes as ^X.Y.Z and tolerates sibling minor/patch bumps without republish. Bumps that cross the caret floor (i.e. major) still force MODE=full via Phase 2's auto-fallback, so backward walk does not need to handle them.
Reality check on incremental savings. Because the repo still has many workspace:* cross-deps under dependencies (notably rich-compose depending on every rich-ext-* / rich-renderer-* / rich-litexml / rich-style-token via workspace:*), the backward walk over dependencies can rapidly snowball: republishing any one of those forces rich-compose in, which forces all its other workspace deps in, which forces everything depending on them in, … In practice incremental closures touching the renderer / extension subtree converge on full anyway. That is expected behaviour, not a bug — the alternative is shipping a broken install graph. If you want incremental to stay genuinely small over time, the fix is migrating the offending dependencies: workspace:* to workspace:^ (a tarball-format change requiring a one-time republish of every consumer), the same migration the v0.26.3 cycle did for peerDependencies.
case "$MODE" in
full)
# Every @haklex/* except the demo
PUBLISH_SET=$(pnpm -r ls --depth -1 --json \
| jq -r '.[].name' | grep '^@haklex/' | grep -v '@haklex/rich-editor-demo')
;;
incremental)
# Bidirectional closure: forward (deps/peers/optional) + backward (exact-pin peerDeps).
# Iterate to fixed point.
declare -A IN_SET
for p in $CHANGED_PKGS; do IN_SET["@haklex/$p"]=1; done
changed=1
while [ "$changed" -eq 1 ]; do
changed=0
# Forward: pkg's workspace deps/peers/optional -> add target
for pkg in "${!IN_SET[@]}"; do
manifest="packages/${pkg#@haklex/}/package.json"
[ -f "$manifest" ] || continue
while read -r dep; do
[ -z "$dep" ] && continue
[ "$dep" = "@haklex/rich-editor-demo" ] && continue
if [ -z "${IN_SET[$dep]:-}" ]; then
IN_SET[$dep]=1
changed=1
fi
done < <(jq -r '
((.dependencies // {}) + (.peerDependencies // {}) + (.optionalDependencies // {}))
| to_entries[]
| select(.key | startswith("@haklex/"))
| select(.value | startswith("workspace:"))
| .key
' "$manifest")
done
# Backward: any sibling whose deps/optional/peers exact-pin (workspace:*) a member of IN_SET
for sib in packages/*/package.json; do
sib_name=$(jq -r '.name' "$sib")
[ "$sib_name" = "@haklex/rich-editor-demo" ] && continue
[ -n "${IN_SET[$sib_name]:-}" ] && continue
while read -r ref; do
[ -z "$ref" ] && continue
if [ -n "${IN_SET[$ref]:-}" ]; then
IN_SET[$sib_name]=1
changed=1
break
fi
done < <(jq -r '
((.dependencies // {}) + (.optionalDependencies // {}) + (.peerDependencies // {}))
| to_entries[]
| select(.key | startswith("@haklex/"))
| select(.value == "workspace:*")
| .key
' "$sib")
done
done
PUBLISH_SET=$(printf '%s\n' "${!IN_SET[@]}" | sort)
;;
esac
echo "Publish set ($MODE): $(wc -l <<< "$PUBLISH_SET") packages"
Devdeps are excluded from both directions (consumers never see them).
Print which packages were absorbed by closure expansion, separating forward from backward so the user can see why PUBLISH_SET is larger than CHANGED_PKGS:
Closure expansion: rich-editor, rich-editor-ui, rich-ext-gallery, rich-headless (changed)
+ rich-style-token (forward: workspace dep of rich-editor)
+ rich-ext-chat, rich-ext-code-snippet, rich-ext-dynamic, rich-ext-embed,
rich-ext-excalidraw, rich-ext-nested-doc, rich-plugin-*, rich-renderer-*
(backward: exact peer-pin workspace:* on rich-editor / rich-editor-ui / rich-style-token)
= 27 packages
Compute topological order from the workspace graph and intersect with PUBLISH_SET:
pnpm -r ls --depth -1 --json
Typical leaf-first order (full set): rich-style-token → rich-headless → rich-litexml → rich-editor-ui → rich-editor → renderer packages → plugin packages → extension packages → rich-compose (composition + SSR top of tree) → rich-litexml-cli (litexml binary; depends on the freshly built rich-compose/rich-headless/rich-litexml dists).
In incremental mode, walk the same topological order but skip any package not in PUBLISH_SET. Keep the leaf-first ordering — even when only a subset is published, leaves must land first so a downstream consumer never sees a publish that resolves against an unpublished dep.
Publish one at a time, polling the registry after each (npm CDN propagation typically lags 30–120 s):
for pkg in $PUBLISH_SET_TOPO; do
pnpm --filter "$pkg" publish --no-git-checks
until npm view "$pkg@$NEW_VERSION" version > /dev/null 2>&1; do sleep 5; done
done
Do not proceed to downstream updates until every published package is resolvable from the registry. Packages not in PUBLISH_SET remain on their previously published version on the registry — that is the desired behaviour in incremental mode and the reason downstream pin rewriting in Phase 6 is also scoped to PUBLISH_SET.
Phase 4.5 — CLI binary smoke (@haklex/rich-litexml-cli)
Run only if @haklex/rich-litexml-cli ∈ PUBLISH_SET OR @haklex/rich-compose ∈ PUBLISH_SET. Otherwise the CLI binary in the wild is unchanged and resolves against an already-validated rich-compose tarball — print CLI smoke skipped (neither rich-litexml-cli nor rich-compose in PUBLISH_SET) and continue to Phase 5.
The CLI is the only published package that runs end-user code through a bin field, and it loads @haklex/rich-compose dist assets at runtime via require.resolve. A successful pnpm publish only proves the tarball uploaded — it does not prove the binary can boot. Run a one-shot smoke against the freshly published tarball:
Pin the CLI version under test to whichever copy actually exists on the registry: if the CLI was republished this cycle, that's $NEW_VERSION; otherwise it's the prior published version (read straight from the registry — the local package.json may have advanced under bumpp without a publish):
if grep -qxF '@haklex/rich-litexml-cli' <<< "$PUBLISH_SET"; then
CLI_VER="$NEW_VERSION"
else
CLI_VER=$(npm view @haklex/rich-litexml-cli version)
fi
npx --yes -p "@haklex/rich-litexml-cli@$CLI_VER" \
litexml '<p>release smoke</p>' --format json --compact \
| jq -e '.root.children[0].type == "paragraph"' > /dev/null
The smoke is still meaningful when only rich-compose was republished: the existing CLI tarball's ^x.y.z range now resolves to the new compose, so we are validating exactly the install graph downstream users will see.
If the smoke fails, do not proceed to Phase 5 — fall back to Phase 8a. Typical causes:
- Missing
dist/style.cssordist/litexml-html-preview-client.jsin the published@haklex/rich-composetarball →pnpm run build:packagesdid not run cleanly; republishrich-composefirst. dependencies.@haklex/rich-composepinned to a stale version (CLI republished but its manifest's compose pin didn't advance) →pnpm bumpp -rdid not update workspace cross-deps; verify the CLI'spackage.jsonresolves to a range that satisfies the freshly publishedrich-compose. Only applies when CLI is inPUBLISH_SET.- Bin missing the
#!/usr/bin/env nodeshebang → vite config regression inpackages/rich-litexml-cli/vite.config.ts. - (Incremental, compose-only republish) The currently-published CLI's
^x.y.zrange doesn't cover the newrich-composeminor/major — should be impossible insideincremental(Phase 2 auto-fallback catches majors), but log it and fall through tofullif it ever fires.
Phase 5 — Commit, tag, push haklex
git add packages/*/package.json pnpm-lock.yaml
git commit -m "release: v$NEW_VERSION"
git tag "v$NEW_VERSION"
git push
git push origin "v$NEW_VERSION"
Only stage the bumped manifests and lockfile. If the worktree has unrelated edits, stop and ask the user.
Phase 4 invoked bumpp -r --no-tag to defer tagging until after manifests land on main — keep that flag so a single annotated tag points at the release commit, not at the pre-publish state.
Phase 5.5 — Publish GitHub release (note auto-authored, published directly)
Release notes (incl. breaking changes, new features, migration steps) live on GitHub Releases. The skill authors the note from the changeset and publishes the release directly — no draft, no review gate. The orchestrator is fully autonomous; downstream propagation in Phase 6+ proceeds in parallel with the release going live.
Build the note from
git logand Phase 2's classification. Group commits by type, surface breaking changes first:COMMITS=$(git log --pretty=format:'- %s (%h)' "$LAST"..HEAD) # Bucket lines by leading conventional-commit type: # feat!: / fix!: / BREAKING CHANGE in body → ## Breaking Changes # feat: → ## Features # fix: → ## Bug Fixes # refactor: / perf: / docs: / chore: → ## OtherHeader the note with the Phase 2 bump classification table so readers see why the level was chosen.
Write the note to a temp file:
NOTE=$(mktemp) cat > "$NOTE" << 'EOF' ## Summary <one-paragraph synthesis of the changeset — written by the orchestrator> ## Breaking Changes <bullets — explicit migration steps per item, or "None"> ## Features <bullets> ## Bug Fixes <bullets> ## Bump rationale <Phase 2 table> EOFPublish the release bound to the pushed tag (no
--draft):gh release create "v$NEW_VERSION" \ --title "v$NEW_VERSION" \ --notes-file "$NOTE" \ --verify-tag--verify-tagfails fast if Phase 5'sgit push origin "v$NEW_VERSION"didn't land — do not silently fall back to--target HEAD. Print the published release URL in the Phase 9 summary; no edit URL is needed since the release is already live.
If gh is missing or unauthenticated (gh auth status fails), stop and ask the user — do not skip the release step and push downstream anyway. The release is a required artefact, not an optional convenience.
Phase 6 — Downstream update in parallel worktrees
Target branch is always the downstream's default branch (origin/HEAD — typically main or master), regardless of what branch the local checkout is currently on. The bump lands there so all feature branches can rebase/merge it in. Never target a feature branch — that strands the bump in a silo.
Derive the default branch per repo — do not guess:
declare -A DEFAULT
for repo in Yohaku mx-core; do
git -C "/Users/innei/git/innei-repo/$repo" remote set-head origin --auto > /dev/null 2>&1
DEFAULT[$repo]=$(git -C "/Users/innei/git/innei-repo/$repo" symbolic-ref refs/remotes/origin/HEAD --short | sed 's#^origin/##')
done
If symbolic-ref fails (no origin/HEAD), stop and ask the user which branch is canonical.
Create a disposable worktree per downstream, branched off origin/${DEFAULT[$repo]}:
for repo in Yohaku mx-core; do
D="${DEFAULT[$repo]}"
git -C "/Users/innei/git/innei-repo/$repo" fetch origin "$D"
git -C "/Users/innei/git/innei-repo/$repo" worktree add \
"/tmp/release-$repo-$NEW_VERSION" -b "chore/haklex-$NEW_VERSION" "origin/$D"
done
# Yohaku quirk: packages/design-system is a symlink to ../design-oss/design-system
# (sibling directory outside the repo). git worktree does NOT materialize that
# target, so pnpm install fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for
# @yohaku/design-system. Copy the sibling dir in, then strip its embedded .git
# so the symlink resolves locally.
if [ -d "/tmp/release-Yohaku-$NEW_VERSION" ]; then
rm -rf "/tmp/release-Yohaku-$NEW_VERSION/design-oss"
cp -R "/Users/innei/git/innei-repo/Yohaku/design-oss/" \
"/tmp/release-Yohaku-$NEW_VERSION/design-oss/"
rm -rf "/tmp/release-Yohaku-$NEW_VERSION/design-oss/.git"
fi
The chore/haklex-$NEW_VERSION branch is temp — it exists only for worktree isolation and is never pushed to origin.
In each worktree, only rewrite pins for packages in PUBLISH_SET. Packages that were not republished kept their old version on the registry — re-pinning them to $NEW_VERSION would 404. For each downstream file from the Repo layout table:
# Pseudo: walk every "@haklex/<pkg>": "<old>" line in the manifest, only
# rewrite when "@haklex/<pkg>" is in $PUBLISH_SET.
for pkg in $(jq -r '.dependencies | keys[] | select(startswith("@haklex/"))' "$MANIFEST"); do
if grep -qxF "$pkg" <<< "$PUBLISH_SET"; then
# Edit pkg pin → $NEW_VERSION
fi
done
Use Edit (not sed -i) so the diff is reviewable. Then pnpm install. In incremental mode some downstream @haklex/* pins will remain at their prior version — that is correct.
Commit-ready files per repo (re-derive at release time — these are the historically observed sets, not a guarantee):
| Repo | Stage these |
|---|---|
| Yohaku | apps/web/package.json, root pnpm-lock.yaml. Next.js may regenerate apps/web/next-env.d.ts during install/build — git checkout -- that file before committing. |
| mx-core | apps/admin/package.json, apps/core/package.json, packages/cli/package.json, packages/editor/package.json, root pnpm-lock.yaml, and pnpm-workspace.yaml if pnpm install auto-added a minimumReleaseAgeExclude entry (benign — keep). |
Always grep the full repo for @haklex/* consumers — do NOT trust the table to be exhaustive. mx-core has historically grown new manifests under packages/* (cli, editor); missing them produces duplicate-version installs (TS2322 across LitexmlRegistry heap boundary in admin's typecheck — v0.26.6 anchor). At the start of Phase 6, run:
grep -lrn '"@haklex/' /tmp/release-$repo-$NEW_VERSION --include=package.json | grep -v node_modules
and apply the pin-rewrite loop to every hit, not just the ones in the table.
If the worktree is dirty beyond those files, stop and ask the user — do NOT git add -A.
Phase 7 — Smoke tests (parallel)
In each downstream worktree, dispatch an independent subagent to run:
pnpm typecheck(orpnpm -r exec tsc --noEmitif no script)pnpm build- One E2E — use the repo's existing smoke journey. If none exists, skip with a warning; do not invent one.
Run the repos in parallel (one subagent per worktree). Collect pass/fail per repo. Proceed only when all green.
Phase 8a — Failure recovery (on smoke or publish failure)
Local revert (safe, automatic):
# If the GitHub release already published in Phase 5.5, delete it. The # release page is technically visible to the public for the brief window # between Phase 5.5 and a Phase 7 failure, but no human is expected to # have consumed it yet — deletion is still the right call. gh release delete "v$NEW_VERSION" --yes --cleanup-tag 2> /dev/null || true # --cleanup-tag also drops the tag on the remote; fall through to the # local tag cleanup below in case the release was never created. git -C haklex tag -d "v$NEW_VERSION" 2> /dev/null || true git -C haklex push origin ":refs/tags/v$NEW_VERSION" 2> /dev/null || true git -C haklex reset --hard HEAD~1 # undo "release: v$NEW_VERSION" git -C haklex push --force-with-lease # ONLY if already pushed — ASK user first for repo in Yohaku mx-core; do git -C "/Users/innei/git/innei-repo/$repo" worktree remove "/tmp/release-$repo-$NEW_VERSION" --force git -C "/Users/innei/git/innei-repo/$repo" branch -D "chore/haklex-$NEW_VERSION" doneThe release was published live in Phase 5.5, so during the brief window between Phase 5.5 and a downstream failure it is technically public. If any downstream commit already merged or any external party has consumed the published note, treat the version as in-the-wild — fall through to step 2 (publish a fix as a new patch) instead of deleting the release.
npm unpublish (risky — always ask user first): within 72 h of publish, if no other package has installed it:
npm unpublish "@haklex/$pkg@$NEW_VERSION"Never unpublish without explicit user confirmation. Once the 72 h window is gone, publish a new patch with the fix instead.
Root-cause triage — classify:
typecheckfail → breaking API change not reflected in semver; re-run Phase 2buildfail → missing export / peer dep / circular workspace edgee2efail → runtime regression; correlate with Phase 2 diff hunks
Report the offending package, the diff, and the recommended fix.
Phase 8b — Success: push commit directly to downstream default branch
Downstream bumps are pure version pins — no code review needed. Push direct, no PR. Never target a feature branch.
In each worktree (branched off origin/${DEFAULT[$repo]} in Phase 6):
D="${DEFAULT[$repo]}" # e.g. main or master
git add <files from table>
git commit -m "$(cat <<EOF
chore(deps): bump @haklex/* to $NEW_VERSION
Upstream: <git log --oneline $LAST..HEAD from haklex>
EOF
)"
# Refresh in case the default branch advanced during the release window
git fetch origin "$D"
git rebase "origin/$D"
# Push the commit straight to the default branch
git push origin "HEAD:$D"
If git rebase surfaces conflicts, stop and ask the user. Do not --skip or --abort silently.
After a successful push, clean up the disposable temp branch:
git -C "/Users/innei/git/innei-repo/$repo" worktree remove "/tmp/release-$repo-$NEW_VERSION"
git -C "/Users/innei/git/innei-repo/$repo" branch -D "chore/haklex-$NEW_VERSION"
Never push the chore/haklex-$NEW_VERSION branch itself to origin — it exists purely for worktree isolation.
Phase 9 — Final summary
Print:
| Repo | Branch | Commit SHA | Bump | Published (n / total) | Tests (typecheck / build / e2e) |
|---|---|---|---|---|---|
| haklex | main | … | … | 3 / 15 (rich-editor, rich-compose, rich-ext-chat) | — |
| Yohaku | main | … | — | 3 pins bumped | ✅ / ✅ / ✅ |
| mx-core | master | … | — | admin: 3 pins, core: 1 pin (rich-headless) | ✅ / ✅ / (skipped) |
For MODE=full, the haklex row reads 15 / 15 (full) and each downstream row reads N pins bumped (full). For MODE=incremental, list the actually-published package names inline so the user can verify the scope matched intent.
Then surface the published GitHub release URL as a separate, prominent line:
✅ Release published: https://github.com/Innei/haklex/releases/tag/v$NEW_VERSION
The release is live — no further user action required. Mark the release as complete in the summary.
Quick reference
| Step | Command |
|---|---|
| Last release SHA | git log --grep='^release: v' -n1 --format=%H |
| Mode detect | grep -iqE '(^|[^a-z])(full|全量|all)([^a-z]|$)' <<<"$INVOCATION_TEXT" && echo full || echo incremental |
| Changed pkgs | git diff --name-only $LAST..HEAD -- 'packages/*/src/**' |
| Publish set | full → all @haklex/* sans demo. incremental → bidirectional closure of CHANGED_PKGS: forward over dependencies + peerDependencies + optionalDependencies (any workspace: prefix), backward over sibling exact-pins (workspace:* only — workspace:^ is skipped) in all three sections. Iterate to fixed point. See Phase 4 for the script. |
| Export diff | diff <(git show $LAST:…/index.ts | grep ^export) <(git show HEAD:…/index.ts | grep ^export) |
| Peer audit | jq '.dependencies, .peerDependencies' packages/<pkg>/package.json |
| Bump | pnpm bumpp -r <level> --no-git --no-tag |
| Build | pnpm run build:packages |
| Publish one | pnpm --filter @haklex/<pkg> publish --no-git-checks |
| Registry poll | until npm view @haklex/<pkg>@$V version; do sleep 5; done |
| CLI smoke | npx --yes -p @haklex/rich-litexml-cli@$CLI_VER litexml '<p>x</p>' --format json --compact ($CLI_VER = $V if in PUBLISH_SET, else npm view @haklex/rich-litexml-cli version) |
| Default branch | git -C <repo> symbolic-ref refs/remotes/origin/HEAD --short | sed 's#^origin/##' (main/master) |
| Worktree | git worktree add /tmp/release-<repo>-$V -b chore/haklex-$V origin/$D |
| Push downstream | git push origin HEAD:$D (after git fetch origin $D && git rebase origin/$D) |
| Tag haklex | git tag v$V && git push origin v$V (after commit, before downstream) |
| Publish release | gh release create v$V --title v$V --notes-file $NOTE --verify-tag (no --draft — publish directly) |
| Release URL | https://github.com/Innei/haklex/releases/tag/v$V (live immediately after publish) |
| Rollback release | gh release delete v$V --yes --cleanup-tag (only on Phase 8a failure path) |
Common mistakes
| Mistake | Fix |
|---|---|
| Asking the caller for release metadata | Infer the changeset summary and affected packages from git log and git diff |
| Defaulting to full release every cycle | Default is incremental. Only enter full via user keyword (full/全量/all) or Phase 2 major auto-fallback |
Treating a major bump as safely incremental |
A major breaks every cross-dep pin in unpublished packages. Phase 2 must flip MODE=full and republish everything |
Equating PUBLISH_SET with CHANGED_PKGS in incremental mode |
This repo uses workspace:* — pnpm publishes those as exact versions. PUBLISH_SET must be the bidirectional closure of CHANGED_PKGS: forward over dependencies + peerDependencies + optionalDependencies, backward over all three sections' workspace:* exact-pins (not just peers). Forward-only misses the reverse failure mode — sibling tarballs with stale exact pins force downstream to install a duplicate old copy of the bumped package. Real-world hits: v0.21.0 (forward), v0.26.3 (backward — peers), v0.26.6 (backward — deps) |
Restricting backward walk to peerDependencies |
The v0.26.3 fix only migrated peers to workspace:^. dependencies / optionalDependencies workspace:* still publish as exact and still trigger the duplicate-version failure when stale. Backward walk MUST scan all three manifest sections. Real-world hit: v0.26.6 |
Treating workspace:^ peerDeps as identical to workspace:* |
workspace:* publishes as exact X.Y.Z — a stale sibling will pin the old version and force downstream duplicates. workspace:^ publishes as ^X.Y.Z and tolerates sibling minor/patch bumps without republish. Backward walk in Phase 4 fires only on workspace:* peers; workspace:^ peers are intentionally skipped. Phase 2's major auto-fallback covers the case where a bump exits the caret floor |
Re-pinning a downstream @haklex/* not in PUBLISH_SET to $NEW_VERSION |
That version was never published for unchanged packages — pnpm install 404s. Only rewrite pins for packages in PUBLISH_SET |
Running the CLI smoke against $NEW_VERSION when CLI wasn't republished |
The version doesn't exist on the registry. Read the prior version via npm view @haklex/rich-litexml-cli version and smoke against that with the new rich-compose resolved at install |
Bumping package.json but skipping publish in full mode |
full means publish every @haklex/* (sans demo). Don't conflate "didn't change" with "don't publish" when MODE=full |
| Publishing before registry poll succeeds | Downstream pnpm install 404s or resolves stale mirror |
| Treating a Context-creating lib as a regular dep | Promote to peer (reference: commit 88bb7a0 — lucide-react) |
| Attempting per-package bumps | Not supported — shared version; highest-wins. (Per-package publish in incremental mode is fine; it's the version field that stays shared) |
git add -A in a dirty downstream worktree |
Stage only the pinned-version files per Repo layout table |
Running pnpm run release:rich inside this skill |
That script compresses bump/build/publish into one step; this skill needs them separate |
npm unpublish without user confirmation |
Always ask — unpublish is public, permanent, and time-limited |
Force-pushing reverts without --force-with-lease |
Use --force-with-lease; ask user before pushing any force |
| Opening a PR for the downstream bump | Bumps go direct to the default branch — no PR, no gh pr create |
Pushing the chore/haklex-$V branch to origin |
That branch is worktree-local; push commits as HEAD:$D where $D is the default branch |
Skipping git fetch + rebase before push |
Default branch may have advanced during the release; rebase on origin/$D first |
Targeting a feature branch or guessing main |
Always derive $D from origin/HEAD; some repos use master, not main |
| Writing release notes into README | Notes live on GitHub Releases — README only links there; never paste a "What's new" block back into any in-repo doc |
Adding --draft to gh release create |
The orchestrator is autonomous — publish directly. Drafts re-introduce a manual gate that defeats the point |
Skipping the rich-litexml-cli binary smoke |
Bin packages can publish "successfully" yet fail to boot — always run Phase 4.5 |
Skipping --verify-tag on gh release create |
Without it, gh silently creates a tag at HEAD, decoupling the release from the bump commit |
Forgetting --cleanup-tag on rollback |
A lingering tag confuses the next release attempt. gh release delete --cleanup-tag drops both the release and the remote tag in one call |
Red flags — STOP and ask
- No publishable package diff under
packages/*/src/**(regardless of mode — same rule as before; infullmode this means there is genuinely nothing to release) MODE=incrementalbutCHANGED_PKGSis empty — there is nothing to publish; do not silently fall back tofullgit statusdirty in haklex- A published package still 404s from the registry after 5 minutes of polling
- User asks to skip peer-dep audit ("it worked last time")
- Downstream smoke tests pass but build artefacts differ in size >30% from previous release
- Being asked to
npm unpublish, force-push, or revert commits already consumed by others - Downstream repo has no
mainbranch (ask the user which branch is canonical; never guess) - Rebase against
origin/mainsurfaces conflicts (something else landed during the release) mainis protected in a way that rejects direct push (fall back to opening a PR, but ask first)gh auth statusfails orghis not installed — release artefact is required, do not skip- A release for
v$NEW_VERSIONalready exists on GitHub (stale from a previous failed attempt) — delete it viagh release delete v$NEW_VERSION --yes --cleanup-tagbefore re-publishing, orgh release createwill reject as duplicate - Phase 3 audit finds any
@haklex/*peerDep onworkspace:*(must beworkspace:^) — fix the manifest before Phase 4 runsbumpp, otherwise the exact pin gets baked into the published tarball and re-triggers the v0.26.3 duplicate-instance failure
Real-world anchors
88bb7a0— rich-agent-chat + lucide-react peer dep (the motivating Context-mismatch bug)0.0.106..0.0.108— pure patch cadence; the firstminor/majorunder this skill warrants extra cautionv0.21.0— incremental closure bug, forward direction. Initial run published onlyCHANGED_PKGS = {rich-agent-core, rich-ext-ai-agent}. Downstreams hitERR_PNPM_NO_MATCHING_VERSION: No matching version found for @haklex/rich-diff-core@0.21.0becauseworkspace:*had published as exact0.21.0but the dep wasn't republished. Recovered by publishingrich-litexml@0.21.0+rich-diff-core@0.21.0after the fact. Motivated the Phase 4 forward closure-expansion algorithm.v0.26.3— incremental closure bug, backward direction,peerDependencieshalf. Initial run published onlyCHANGED_PKGS = {rich-editor, rich-editor-ui, rich-ext-gallery, rich-headless}. The forward closure stopped there because none of those reach the rest via deps. However, every unchangedrich-ext-*/rich-plugin-*/rich-renderer-*package's last published tarball (0.26.2) carried exact peer pins"@haklex/rich-editor": "0.26.2"(fromworkspace:*). When downstream bumpedrich-editorto0.26.3, pnpm satisfied each unchanged ext's stale peer by installing a second copy ofrich-editor@0.26.2, producing two physical@haklex/rich-editordirectories undernode_modules. Tworich-editordirs → two bundledlexicalruntimes → insertingrich-ext-chat'sChatNodeinto the 0.26.3 editor triggered Lexical error #8 (Cannot find node by key) because the class registered against the 0.26.2 lexical heap was not the class the 0.26.3 editor reconciled against. Two-part fix: (1) migrated every@haklex/*peerDep in this repo fromworkspace:*toworkspace:^so publish writes^X.Y.Zinstead of exact, letting siblings tolerate minor/patch bumps without republish; (2) added the backward closure walk to Phase 4 so existingworkspace:*peer pins (and any future ones) force the peer-pinner intoPUBLISH_SET. The migration in (1) prevents recurrence going forward; the walk in (2) protects the transitional period during which already-published tarballs still carry exact peer pins.v0.26.6— incremental closure bug, backward direction,dependencieshalf (the other shoe dropping from v0.26.3). Initial run started incremental withCHANGED_PKGS = {rich-renderer-image}(manifest-only —jotaimigrated fromdependenciestopeerDependencies). Forward closure expanded to 5 packages (rich-editor + ui + style-token + headless viapeerDependencies: workspace:^). After publishing those 5, audit revealedrich-compose@0.26.5/dependenciesstill pinned"@haklex/rich-renderer-image": "0.26.5"and"@haklex/rich-style-token": "0.26.5"exactly — both bumped to 0.26.6 on the registry. The v0.26.3 fix only migratedpeerDependenciestoworkspace:^;dependenciesandoptionalDependenciesare stillworkspace:*and still publish as exact. Same backward failure mode, different manifest section. Re-publishingrich-compose@0.26.6would have cascaded into its own forward closure (everyrich-ext-*/rich-renderer-*/rich-litexmlit depends on atworkspace:*), which in turn would force their backward-pinners in, … converging on the full set anyway. Resolution: switch toMODE=fulland republish all 41. Fix in this skill: (1) backward walk now scansdependencies + optionalDependencies + peerDependencies(not just peers); (2) documented that incremental closures touching the renderer / extension subtree will snowball into full for as long asdependencies: workspace:*remains in use, and the lasting fix is migrating those toworkspace:^too. Also caught: mx-core has additional@haklex/*consumers underpackages/cliandpackages/editornot listed in the original Repo layout table — added the "always grep" rule to Phase 6. Also caught:Yohaku/packages/design-systemis a sibling-directory symlink thatgit worktreedoes not materialize — added thecp -R design-oss/ + rm .gitworkaround to Phase 6.- Previous per-repo playbook:
.claude/commands/release.md(now superseded by this skill)