diff options
| -rw-r--r-- | .claude/skills/release/SKILL.md | 236 | ||||
| -rwxr-xr-x | scripts/release.sh | 508 |
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 "$@" |
