Add npm release tasks

Amolith created

Add mise tasks for package-specific version bumps, package dry-runs, and
npm publishing. Keep package selector validation shared so release
commands reject unknown or duplicate aliases before touching package
versions or publishing.

Change summary

mise.toml                             | 138 +++++++++++++++++++++++++++++
scripts/resolve-release-packages.bash |  45 +++++++++
2 files changed, 183 insertions(+)

Detailed changes

mise.toml 🔗

@@ -20,3 +20,141 @@ run = "bun run typecheck"
 [tasks.check]
 description = "Format, lint, and type-check"
 depends = ["fmt", "lint", "typecheck"]
+
+[tasks."release:bump"]
+description = "Bump one or more package versions"
+usage = '''
+arg "<version>" help="SemVer increment or exact version"
+arg "<packages>" help="Package selectors" var=#true var_min=1
+flag "--preid <preid>" help="Prerelease identifier for pre* increments"
+'''
+shell = "bash -c"
+run = '''
+set -euo pipefail
+
+version=${usage_version:?}
+preid=${usage_preid:-}
+eval "packages=(${usage_packages:?})"
+
+exact_version_re='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
+is_exact_version=false
+if [[ "$version" =~ $exact_version_re ]]; then
+	is_exact_version=true
+fi
+
+case "$version" in
+	patch|minor|major|prepatch|preminor|premajor|prerelease) ;;
+	*)
+		if [[ "$is_exact_version" != true ]]; then
+			echo "Error: unsupported version '$version'" >&2
+			echo "Use patch, minor, major, prepatch, preminor, premajor, prerelease, or an exact SemVer." >&2
+			exit 1
+		fi
+		;;
+esac
+
+if [[ -n "$preid" ]]; then
+	case "$version" in
+		prepatch|preminor|premajor|prerelease) ;;
+		*)
+			echo "Error: --preid only applies to prepatch, preminor, premajor, or prerelease" >&2
+			exit 1
+			;;
+	esac
+fi
+
+resolved_dirs=$(bash scripts/resolve-release-packages.bash "${packages[@]}")
+mapfile -t dirs <<< "$resolved_dirs"
+
+if [[ "$is_exact_version" == true && -n "${dirs[1]:-}" ]]; then
+	echo "Error: exact versions may only be applied to one package at a time" >&2
+	exit 1
+fi
+
+for dir in "${dirs[@]}"; do
+	args=(pm version "$version" --no-git-tag-version)
+	if [[ -n "$preid" ]]; then
+		args+=(--preid "$preid")
+	fi
+	echo "Bumping $dir"
+	(
+		cd "$dir"
+		bun "${args[@]}"
+	)
+done
+'''
+
+[tasks."release:pack"]
+description = "Check and dry-run npm package packing"
+usage = '''
+arg "<packages>" help="Package selectors" var=#true var_min=1
+'''
+shell = "bash -c"
+run = '''
+set -euo pipefail
+
+eval "packages=(${usage_packages:?})"
+resolved_dirs=$(bash scripts/resolve-release-packages.bash "${packages[@]}")
+mapfile -t dirs <<< "$resolved_dirs"
+
+mise run check
+
+for dir in "${dirs[@]}"; do
+	echo "Packing $dir (dry run)"
+	bunx npm pack --dry-run "./$dir"
+done
+'''
+
+[tasks."release:publish"]
+description = "Check, dry-run, and publish npm packages"
+usage = '''
+arg "<packages>" help="Package selectors" var=#true var_min=1
+flag "--tag <tag>" help="npm dist-tag" default="latest"
+flag "--otp <otp>" help="One-time password for npm two-factor auth"
+'''
+shell = "bash -c"
+run = '''
+set -euo pipefail
+
+package_version() {
+	bun -e 'const fs = require("node:fs"); const path = require("node:path"); const pkg = JSON.parse(fs.readFileSync(path.join(process.argv[1], "package.json"), "utf8")); console.log(pkg.version);' "$1"
+}
+
+eval "packages=(${usage_packages:?})"
+tag=${usage_tag:?}
+otp=${usage_otp:-}
+resolved_dirs=$(bash scripts/resolve-release-packages.bash "${packages[@]}")
+mapfile -t dirs <<< "$resolved_dirs"
+
+if [[ -n "$otp" && -n "${dirs[1]:-}" ]]; then
+	echo "Error: --otp may only be used with one package at a time" >&2
+	echo "Use an npm automation token for multi-package publishing, or publish each package separately with a fresh OTP." >&2
+	exit 1
+fi
+
+for dir in "${dirs[@]}"; do
+	version=$(package_version "$dir")
+	if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- && "$tag" == latest ]]; then
+		echo "Error: refusing to publish prerelease $dir@$version with tag latest" >&2
+		echo "Use --tag beta or another prerelease dist-tag." >&2
+		exit 1
+	fi
+done
+
+mise run check
+
+publish_args=(--access public --tag "$tag")
+if [[ -n "$otp" ]]; then
+	publish_args+=(--otp "$otp")
+fi
+
+for dir in "${dirs[@]}"; do
+	echo "Publishing $dir (dry run)"
+	bunx npm publish --dry-run "${publish_args[@]}" "./$dir"
+done
+
+for dir in "${dirs[@]}"; do
+	echo "Publishing $dir"
+	bunx npm publish "${publish_args[@]}" "./$dir"
+done
+'''

scripts/resolve-release-packages.bash 🔗

@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# SPDX-License-Identifier: CC0-1.0
+
+set -euo pipefail
+
+valid_packages="answer, handoff, personas, questionnaire"
+
+resolve_package_dir() {
+	case "$1" in
+		answer|pi-answer|@amolith/pi-answer) printf '%s\n' packages/answer ;;
+		handoff|pi-handoff|@amolith/pi-handoff) printf '%s\n' packages/handoff ;;
+		personas|pi-personas|@amolith/pi-personas) printf '%s\n' packages/personas ;;
+		questionnaire|pi-questionnaire|@amolith/pi-questionnaire) printf '%s\n' packages/questionnaire ;;
+		*)
+			echo "Error: unknown package '$1'" >&2
+			echo "Valid packages: $valid_packages" >&2
+			exit 1
+			;;
+	esac
+}
+
+if [[ $# -eq 0 ]]; then
+	echo "Error: at least one package is required" >&2
+	echo "Valid packages: $valid_packages" >&2
+	exit 1
+fi
+
+results=()
+for package in "$@"; do
+	dir=$(resolve_package_dir "$package")
+	if [[ ${#results[@]} -gt 0 ]]; then
+		for existing in "${results[@]}"; do
+			if [[ "$existing" == "$dir" ]]; then
+				echo "Error: multiple selectors resolved to '$dir' (duplicate triggered by '$package')" >&2
+				exit 1
+			fi
+		done
+	fi
+	results+=("$dir")
+done
+
+printf '%s\n' "${results[@]}"