aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.claude/skills/release/SKILL.md236
-rwxr-xr-xscripts/release.sh508
2 files changed, 744 insertions, 0 deletions
diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md
new file mode 100644
index 00000000..7aa2d608
--- /dev/null
+++ b/.claude/skills/release/SKILL.md
@@ -0,0 +1,236 @@
+---
+name: release
+description: >
+ Orchestrate a multi-step Atuin CLI release — version bumping, changelog
+ generation, PR creation, tagging, and crates.io publishing. Invoke with
+ /release or /release <version>.
+disable-model-invocation: true
+argument-hint: [version]
+---
+
+# Atuin CLI Release
+
+You are orchestrating a release of the Atuin CLI. Follow the steps below
+**in order**, pausing at each checkpoint for user confirmation. Do not skip
+steps or combine them.
+
+## Current State
+
+- Workspace version: !`sed -n '/^\[workspace\.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml`
+- Latest tag: !`git describe --tags --abbrev=0 2>/dev/null || echo "none"`
+- Suggested next version: !`git-cliff --bumped-version 2>/dev/null | sed 's/^v//' || echo "(unknown)"`
+
+---
+
+## Step 1 — Check Dependencies
+
+Verify these tools are installed: `git`, `gsed`, `cargo`, `gh`, `git-cliff`.
+
+Use `command -v` for each. If any are missing, report which ones and stop.
+
+---
+
+## Step 2 — Determine Version
+
+The target version may be provided as `$ARGUMENTS`. If it's empty, use
+AskUserQuestion to ask for the new version (show the current state above
+for reference).
+
+After determining the version:
+- If it contains a `-` (e.g. `18.15.0-beta.1`), it is a **prerelease**.
+ Note this — it affects changelog and publish behavior later.
+- Show the user: `current → new` and whether it's a prerelease.
+- **Checkpoint:** Ask the user to confirm before proceeding.
+
+---
+
+## Step 3 — Set Up Working Directory
+
+Clone a fresh copy into a temp directory:
+
+```bash
+WORKDIR=$(mktemp -d)
+git clone git@github.com:atuinsh/atuin.git "$WORKDIR"
+```
+
+Print the working directory path so the user can find it if needed.
+All subsequent Bash commands run from `$WORKDIR`.
+
+---
+
+## Step 4 — Create Branch & Update Versions
+
+1. Create a release branch named after the version (no `v` prefix):
+ `git checkout -b <VERSION>`
+
+2. Replace the old version with the new one in all `Cargo.toml` files.
+ **Escape dots** in the old version so sed treats them literally:
+
+ ```bash
+ VERSION_PATTERN="${OLD_VERSION//./\\.}"
+ find . -type f -name 'Cargo.toml' -not -path './.git/*' \
+ -exec gsed -i "s/$VERSION_PATTERN/$NEW_VERSION/g" {} \;
+ ```
+
+3. Run `cargo check` to update `Cargo.lock`.
+
+4. Show `git diff --stat` and the version-related lines from the diff:
+ ```bash
+ git diff --unified=0 -- '*.toml' | grep -E '^\+.*version' | grep -v '^\+\+\+'
+ ```
+
+5. Verify the workspace version was actually updated by re-reading it
+ from `Cargo.toml`.
+
+6. **Checkpoint:** Show the diff summary and ask the user to confirm the
+ version changes look correct.
+
+---
+
+## Step 5 — Update Changelog
+
+The changelog strategy differs for prereleases vs stable releases:
+
+- **Prerelease:** Maintain a running `## [unreleased]` section containing
+ all changes since the last stable release. Use:
+ `git-cliff --unreleased --strip all`
+ (cliff.toml's `ignore_tags` already ignores beta/alpha tags, so
+ `--unreleased` spans back to the last stable release automatically.)
+
+- **Stable release:** Generate a versioned entry that replaces the
+ `[unreleased]` section. Use:
+ `git-cliff --unreleased --tag "v<VERSION>" --strip all`
+
+Then update `CHANGELOG.md`:
+
+1. If an existing `## [unreleased]` or `## [Unreleased]` section exists,
+ **remove it entirely** (the heading and all content up to the next
+ `## ` heading).
+
+2. Insert the new entry before the first existing `## ` version heading.
+
+3. **Checkpoint:** Read and display the new changelog entry to the user.
+ Ask if they want any edits. If so, make the requested changes using
+ the Edit tool. Repeat until they're satisfied.
+
+---
+
+## Step 6 — Commit & Push
+
+Stage all changes and commit:
+
+```
+chore(release): prepare for release <VERSION>
+```
+
+Push the branch with `--set-upstream origin`.
+
+---
+
+## Step 7 — Create PR & Wait for Merge
+
+### Create the PR
+
+Extract the changelog entry body (everything between the new `## ` heading
+and the next one) for the PR description.
+
+For prereleases, the heading to match is `## [unreleased]`.
+For stable releases, it's `## <VERSION>` (escape dots in the awk pattern).
+
+Create the PR:
+```bash
+gh pr create \
+ --title "chore(release): prepare for release <VERSION>" \
+ --body "<body with changelog>" \
+ --repo atuinsh/atuin
+```
+
+Show the PR URL to the user.
+
+### Wait for merge
+
+Start a **persistent Monitor** that polls the PR status every 30 seconds.
+The monitor script must:
+- Emit on **every** terminal state (`MERGED`, `CLOSED`), not just success
+- Include CI check summary in each poll so the user sees progress
+- Handle transient API errors gracefully (don't crash on a single failure)
+- Exit 0 on `MERGED`, exit 1 on `CLOSED`
+
+Example monitor script (substitute the actual PR number):
+```bash
+while true; do
+ json=$(gh pr view PR_NUM --repo atuinsh/atuin --json state,statusCheckRollup 2>/dev/null) || { echo "API error, retrying..."; sleep 30; continue; }
+ state=$(echo "$json" | jq -r '.state')
+ case "$state" in
+ MERGED) echo "PR #PR_NUM has been merged!"; exit 0 ;;
+ CLOSED) echo "PR #PR_NUM was closed without merging."; exit 1 ;;
+ esac
+ checks=$(echo "$json" | jq -r '[.statusCheckRollup[]? | .conclusion // .status] | group_by(.) | map("\(.[0]): \(length)") | join(", ")' 2>/dev/null)
+ echo "PR #PR_NUM is $state — checks: ${checks:-pending}"
+ sleep 30
+done
+```
+
+Tell the user to go review and merge the PR. While the monitor runs, you
+can respond to other questions — the monitor notifications will arrive
+asynchronously.
+
+When the monitor reports `MERGED`, proceed to the next step.
+If it reports `CLOSED`, inform the user and stop the release.
+
+---
+
+## Step 8 — Tag Release
+
+Back in the working directory:
+
+```bash
+git checkout main
+git pull
+git tag "v<VERSION>"
+git push --tags
+```
+
+Tell the user the tag was pushed and the release CI workflow has been
+triggered.
+
+---
+
+## Step 9 — Publish to crates.io
+
+**If this is a prerelease**, skip this step entirely and tell the user.
+
+**If this is a stable release**, ask the user whether to publish.
+
+If yes, publish each crate **in dependency order** using `--no-verify`
+(the code already passed CI, and verification fails when crates.io
+hasn't indexed a freshly-published dependency yet):
+
+```
+atuin-common, atuin-client, atuin-ai, atuin-dotfiles, atuin-history,
+atuin-nucleo/matcher, atuin-nucleo, atuin-daemon, atuin-kv,
+atuin-scripts, atuin-server-database, atuin-server-postgres,
+atuin-server-sqlite, atuin-server, atuin-hex, atuin
+```
+
+For each crate, run from `crates/<name>`:
+```bash
+cargo publish --no-verify 2>&1
+```
+
+If it fails with "already uploaded", report it as a skip (not an error) —
+some crates like `atuin-nucleo` are versioned independently and may
+already be published at their current version.
+
+If it fails for any other reason, stop and report the error.
+
+---
+
+## Completion
+
+Summarize what was done:
+- Version released
+- PR URL
+- Tag name
+- Which crates were published (if any)
+- Working directory path and how to clean it up (`rm -rf`)
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 00000000..9ee36424
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,508 @@
+#!/usr/bin/env bash
+#
+# Atuin CLI Release Script
+#
+# But first — make a cup of tea. Releases without tea are a crime. 🫖
+#
+
+set -euo pipefail
+
+WORKDIR=""
+
+cleanup_on_error() {
+ if [[ $? -ne 0 && -n "$WORKDIR" ]]; then
+ echo ""
+ echo -e " \033[0;31m✗\033[0m Script failed. Working directory preserved at:"
+ echo -e " \033[2m$WORKDIR\033[0m"
+ fi
+}
+trap cleanup_on_error EXIT
+
+# ════════════════════════════════════════════════════════════════════
+# Formatting
+# ════════════════════════════════════════════════════════════════════
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+MAGENTA='\033[0;35m'
+BOLD='\033[1m'
+DIM='\033[2m'
+NC='\033[0m'
+
+info() { echo -e " ${BLUE}▸${NC} $*"; }
+success() { echo -e " ${GREEN}✓${NC} $*"; }
+warn() { echo -e " ${YELLOW}⚠${NC} $*"; }
+die() { echo -e " ${RED}✗${NC} $*" >&2; exit 1; }
+
+step() {
+ echo ""
+ echo -e " ${MAGENTA}${BOLD}━━━ $* ━━━${NC}"
+ echo ""
+}
+
+confirm() {
+ echo -en " ${CYAN}▸${NC} $1 ${DIM}[y/N]${NC} "
+ read -r reply
+ [[ "$reply" =~ ^[Yy]$ ]]
+}
+
+# ════════════════════════════════════════════════════════════════════
+# Banner
+# ════════════════════════════════════════════════════════════════════
+
+banner() {
+ echo ""
+ echo -e "${CYAN}"
+ cat <<'BANNER'
+
+ :
+ =#*#=
+ #*+*#.
+ #**++*+
+ =#*++++#.
+ **+++++**
+ :#++++++*#.
+ **+++++++*#
+ -#*+++++++*#-
+ ##*****#####*
+ -*##########:.+####*.
+ ***********####*****+
+ :**#############*#+
+ #+----------------*-
+ -*###########**+++##-----=##=-----=##=#
+ .#*-==.. =*------------------*
+ -#-::::::*=----------#*---------=----=--=*
+ +*:::::::--:::::::::::**---+#=----===---=*
+ #*::::::::=::::::::::::**-----+#*=-----+#-
+ .#+::::::::=-::::::::::::**--------=*####
+ #=::::::::=*:::::::::::::+*---------=#-*#=
+ ##-:::-**+=+*-:::::::::::-#=--------=#=##-
+ :#+**+========++=::::-=++=#+--------+#+#*
+ +##++============+*#+=====*+--------+#*#
+ *=*#*+==+**++++*#++*====+###--------*###*
+ *=-*##=+#=-------#**===+#+---------=##*=#
+ *=-=*#**+---------#*+==+#----------*##--#
+ :#*- ###=---------#+*==+#=-------=###=-=#
+ +=---------#+#+++#*++++*##*== .. #
+ ...... +=--*++*+=-#####+-. -+****=.
+ ....... =*== . + ...........
+ ............::-:............................
+ ............................................
+ ....###....########.##.....##.####.##....##.
+ ...##.##......##....##.....##..##..###...##.
+ ..##...##.....##....##.....##..##..####..##.
+ .##.....##....##....##.....##..##..##.##.##.
+ .#########....##....##.....##..##..##..####.
+ .##.....##....##....##.....##..##..##...###.
+ .##.....##....##.....#######..####.##....##.
+ ............................................
+
+ ~ Magical Shell History ~
+ Release Script
+
+BANNER
+ echo -e "${NC}"
+ echo ""
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 1. Dependency check
+# ════════════════════════════════════════════════════════════════════
+
+check_deps() {
+ step "Dependencies"
+
+ local missing=()
+ local deps=(git gsed cargo gh git-cliff)
+
+ for cmd in "${deps[@]}"; do
+ if command -v "$cmd" &>/dev/null; then
+ success "${BOLD}$cmd${NC} ${DIM}$(command -v "$cmd")${NC}"
+ else
+ echo -e " ${RED}✗${NC} ${BOLD}$cmd${NC} not found"
+ missing+=("$cmd")
+ fi
+ done
+
+ if (( ${#missing[@]} )); then
+ echo ""
+ die "Missing required tools: ${missing[*]}\n Install with: ${DIM}brew install ${missing[*]}${NC}"
+ fi
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 2. Clone into working directory
+# ════════════════════════════════════════════════════════════════════
+
+setup_workdir() {
+ step "Working Directory"
+
+ WORKDIR=$(mktemp -d)
+ info "Created ${DIM}$WORKDIR${NC}"
+
+ info "Cloning atuinsh/atuin..."
+ git clone --quiet git@github.com:atuinsh/atuin.git "$WORKDIR"
+ cd "$WORKDIR"
+ success "Repository cloned"
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 3. Version
+# ════════════════════════════════════════════════════════════════════
+
+detect_current_version() {
+ sed -n '/^\[workspace\.package\]/,/^\[/s/^version = "\(.*\)"/\1/p' Cargo.toml
+}
+
+get_version() {
+ step "Version"
+
+ CURRENT_VERSION=$(detect_current_version)
+ info "Current version: ${BOLD}$CURRENT_VERSION${NC}"
+
+ # Suggest the next version based on conventional commits
+ local suggested
+ suggested=$(git-cliff --bumped-version 2>/dev/null | sed 's/^v//' || true)
+ if [[ -n "$suggested" && "$suggested" != "$CURRENT_VERSION" ]]; then
+ info "Suggested next: ${BOLD}$suggested${NC} ${DIM}(based on conventional commits)${NC}"
+ fi
+
+ echo ""
+
+ if [[ -n "${NEW_VERSION:-}" ]]; then
+ info "Using version from environment: ${BOLD}$NEW_VERSION${NC}"
+ else
+ echo -en " ${CYAN}▸${NC} New version ${DIM}(without 'v' prefix)${NC}: "
+ read -r NEW_VERSION
+ fi
+
+ [[ -n "$NEW_VERSION" ]] || die "Version cannot be empty"
+
+ IS_PRERELEASE=false
+ if [[ "$NEW_VERSION" == *-* ]]; then
+ IS_PRERELEASE=true
+ warn "Pre-release detected"
+ fi
+
+ echo ""
+ info "${BOLD}$CURRENT_VERSION${NC} → ${BOLD}$NEW_VERSION${NC}"
+ echo ""
+ confirm "Proceed with release?" || { info "Aborted."; exit 0; }
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 4. Update version numbers
+# ════════════════════════════════════════════════════════════════════
+
+update_versions() {
+ step "Updating Versions"
+
+ info "Creating release branch: ${BOLD}$NEW_VERSION${NC}"
+ git checkout -b "$NEW_VERSION" --quiet
+
+ local version_pattern="${CURRENT_VERSION//./\\.}"
+
+ info "Replacing ${DIM}$CURRENT_VERSION${NC} → ${DIM}$NEW_VERSION${NC} in Cargo.toml files..."
+ find . -type f -name 'Cargo.toml' -not -path './.git/*' \
+ -exec gsed -i "s/$version_pattern/$NEW_VERSION/g" {} \;
+
+ info "Running ${DIM}cargo check${NC} to update Cargo.lock (this may take a moment)..."
+ cargo check --quiet 2>&1 || cargo check
+
+ echo ""
+ info "Changed files:"
+ git diff --stat | sed 's/^/ /'
+
+ echo ""
+
+ # Verify the workspace version was updated
+ local new_ws_version
+ new_ws_version=$(detect_current_version)
+ if [[ "$new_ws_version" == "$NEW_VERSION" ]]; then
+ success "Workspace version updated"
+ else
+ die "Workspace version is '$new_ws_version', expected '$NEW_VERSION'"
+ fi
+
+ # Verify we didn't break anything unexpected — show version-related
+ # lines in the diff for review
+ echo ""
+ info "Version changes in diff:"
+ git diff --unified=0 -- '*.toml' \
+ | grep -E '^\+.*version' \
+ | grep -v '^\+\+\+' \
+ | sed 's/^/ /' || true
+ echo ""
+
+ confirm "Version changes look correct?" || { die "Aborting — fix versions manually in $WORKDIR"; }
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 5. Changelog
+# ════════════════════════════════════════════════════════════════════
+
+update_changelog() {
+ step "Changelog"
+
+ # cliff.toml's ignore_tags already ignores beta/alpha tags, so
+ # --unreleased always gives us everything since the last stable release.
+ #
+ # Prereleases: heading is ## [unreleased] (running tally)
+ # Stable: heading is ## X.Y.Z (versioned entry)
+ local cliff_args=(--unreleased --strip all)
+
+ if $IS_PRERELEASE; then
+ info "Updating ${BOLD}Unreleased${NC} section..."
+ else
+ cliff_args+=(--tag "v$NEW_VERSION")
+ info "Generating changelog for ${BOLD}$NEW_VERSION${NC}..."
+ fi
+
+ local new_entry
+ new_entry=$(git-cliff "${cliff_args[@]}" 2>/dev/null || true)
+
+ # Check if the entry is empty (just a heading with no content)
+ if [[ -z "$new_entry" ]] || [[ "$(echo "$new_entry" | grep -c '[a-zA-Z]')" -le 1 ]]; then
+ warn "No unreleased changes detected by git-cliff"
+ warn "You may want to add entries manually in the editor"
+ if $IS_PRERELEASE; then
+ new_entry="## [unreleased]"
+ else
+ new_entry="## $NEW_VERSION"
+ fi
+ else
+ local commit_count
+ commit_count=$(echo "$new_entry" | grep -c '^- ' || true)
+ success "Generated entry with ${BOLD}$commit_count${NC} item(s)"
+ fi
+
+ # Remove any existing [unreleased] section — we'll replace it with
+ # either an updated unreleased section or a versioned one
+ if grep -qi '^\## \[unreleased\]' CHANGELOG.md; then
+ info "Removing old Unreleased section..."
+ awk '
+ /^## \[[Uu]nreleased\]/ { skip=1; next }
+ /^## / { skip=0 }
+ !skip
+ ' CHANGELOG.md > CHANGELOG.md.tmp
+ mv CHANGELOG.md.tmp CHANGELOG.md
+ fi
+
+ # Insert the new entry before the first existing version heading
+ local insert_line
+ insert_line=$(grep -n '^## ' CHANGELOG.md | head -1 | cut -d: -f1)
+
+ if [[ -n "$insert_line" ]]; then
+ {
+ head -n "$((insert_line - 1))" CHANGELOG.md
+ echo "$new_entry"
+ echo ""
+ echo ""
+ tail -n "+$insert_line" CHANGELOG.md
+ } > CHANGELOG.md.tmp
+ mv CHANGELOG.md.tmp CHANGELOG.md
+ else
+ warn "No existing version headings found — appending to end"
+ echo "" >> CHANGELOG.md
+ echo "$new_entry" >> CHANGELOG.md
+ fi
+
+ echo ""
+ info "Opening CHANGELOG.md in your editor for review..."
+ info "${DIM}Verify the entry, make any edits, then save and close.${NC}"
+ echo ""
+ echo -en " ${DIM}Press Enter to open editor...${NC}"
+ read -r
+ "${EDITOR:-${VISUAL:-vi}}" CHANGELOG.md
+ success "Changelog finalized"
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 6. Commit and push
+# ════════════════════════════════════════════════════════════════════
+
+commit_and_push() {
+ step "Commit & Push"
+
+ git add .
+ git commit --quiet -m "chore(release): prepare for release $NEW_VERSION"
+ success "Committed"
+
+ info "Pushing branch..."
+ git push --quiet --set-upstream origin "$(git branch --show-current)" 2>&1
+ success "Pushed to origin/${BOLD}$NEW_VERSION${NC}"
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 7. Pull request
+# ════════════════════════════════════════════════════════════════════
+
+extract_changelog_entry() {
+ # Extract the changelog body (without the heading) for the entry we just wrote.
+ # Prereleases use ## [unreleased], stable uses ## X.Y.Z
+ local heading
+ if $IS_PRERELEASE; then
+ heading='## \[unreleased\]'
+ else
+ heading="## ${NEW_VERSION//./\\.}"
+ fi
+ awk "/^${heading}/{found=1; next} /^## /{if(found) exit} found" CHANGELOG.md
+}
+
+create_pr() {
+ step "Pull Request"
+
+ local changelog_body
+ changelog_body=$(extract_changelog_entry)
+
+ local pr_body
+ pr_body="Release preparation for v${NEW_VERSION}."
+ if [[ -n "$changelog_body" ]]; then
+ pr_body="$(cat <<EOF
+Release preparation for v${NEW_VERSION}.
+
+## Changelog
+
+$changelog_body
+EOF
+)"
+ fi
+
+ info "Creating PR..."
+ PR_URL=$(gh pr create \
+ --title "chore(release): prepare for release $NEW_VERSION" \
+ --body "$pr_body" \
+ --repo atuinsh/atuin)
+
+ success "PR created: ${BOLD}$PR_URL${NC}"
+
+ local pr_number
+ pr_number=$(echo "$PR_URL" | grep -o '[0-9]*$')
+
+ echo ""
+ info "Waiting for PR #${BOLD}$pr_number${NC} to be merged..."
+ info "${DIM}Review and merge the PR — this script will detect it automatically.${NC}"
+ echo ""
+
+ while true; do
+ local state
+ state=$(gh pr view "$pr_number" --repo atuinsh/atuin --json state --jq '.state' 2>/dev/null || echo "UNKNOWN")
+
+ case "$state" in
+ MERGED)
+ echo ""
+ success "PR #$pr_number merged!"
+ break
+ ;;
+ CLOSED)
+ echo ""
+ die "PR #$pr_number was closed without merging"
+ ;;
+ *)
+ printf "\r ${DIM}⏳ PR #%s is %s — checking again in 5s...${NC} " "$pr_number" "$state"
+ sleep 5
+ ;;
+ esac
+ done
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 8. Tag
+# ════════════════════════════════════════════════════════════════════
+
+tag_release() {
+ step "Tag & Release"
+
+ info "Switching to main and pulling..."
+ git checkout main --quiet
+ git pull --quiet
+
+ info "Creating tag ${BOLD}v$NEW_VERSION${NC}"
+ git tag "v$NEW_VERSION"
+
+ info "Pushing tag..."
+ git push --tags
+ success "Tag ${BOLD}v$NEW_VERSION${NC} pushed — release workflow triggered"
+}
+
+# ════════════════════════════════════════════════════════════════════
+# 9. Publish to crates.io
+# ════════════════════════════════════════════════════════════════════
+
+publish_crates() {
+ step "Publish to crates.io"
+
+ if $IS_PRERELEASE; then
+ warn "Pre-release — skipping crates.io publish"
+ return
+ fi
+
+ if ! confirm "Publish to crates.io?"; then
+ info "Skipping"
+ return
+ fi
+
+ local crates=(
+ atuin-common
+ atuin-client
+ atuin-ai
+ atuin-dotfiles
+ atuin-history
+ atuin-nucleo/matcher
+ atuin-nucleo
+ atuin-daemon
+ atuin-kv
+ atuin-scripts
+ atuin-server-database
+ atuin-server-postgres
+ atuin-server-sqlite
+ atuin-server
+ atuin-hex
+ atuin
+ )
+
+ for crate in "${crates[@]}"; do
+ info "Publishing ${BOLD}$crate${NC}..."
+ local output
+ # --no-verify: skip rebuild during publish — the code already passed
+ # CI, and verification fails on workspace crates whose freshly-published
+ # dependencies haven't been indexed by crates.io yet
+ if output=$(cd "crates/$crate" && cargo publish --no-verify 2>&1); then
+ success "$crate published"
+ elif echo "$output" | grep -q "already uploaded"; then
+ warn "$crate already published, skipping"
+ else
+ echo "$output" >&2
+ die "Failed to publish $crate"
+ fi
+ done
+}
+
+# ════════════════════════════════════════════════════════════════════
+# Main
+# ════════════════════════════════════════════════════════════════════
+
+main() {
+ banner
+ check_deps
+ setup_workdir
+ get_version
+ update_versions
+ update_changelog
+ commit_and_push
+ create_pr
+ tag_release
+ publish_crates
+
+ step "Done 🎉"
+ success "Released ${BOLD}v$NEW_VERSION${NC}"
+ echo ""
+ info "Working directory: ${DIM}$WORKDIR${NC}"
+ info "Clean up with: ${DIM}rm -rf $WORKDIR${NC}"
+ echo ""
+}
+
+main "$@"