Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 36 additions & 64 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ allowed-tools: Bash, Read, Grep, Glob

# Release

Run pre-release checks and create a new bashunit release.

## Arguments

- `$ARGUMENTS` - Version number (optional, e.g., `0.34.0`). If omitted, auto-increments the minor version.
Thin reminder around `./release.sh`. The release script owns the whole
end-to-end flow (version bumps, build, checksum, CHANGELOG, commit, signed
tags, push, GitHub release, `latest` branch). Don't reimplement those steps
here — fix `release.sh` if something is missing.

## Current State

Expand All @@ -21,88 +20,61 @@ Run pre-release checks and create a new bashunit release.
- Working tree: !`git status --short`
- Unreleased changes: !`awk '/^## Unreleased$/,/^## \[/' CHANGELOG.md | head -30`

## Instructions
## Steps

### 1. Pre-flight validation

Run these checks and report pass/fail for each:
### 1. Pre-flight

```bash
# Tests
./bashunit tests/

# Static analysis
make sa

# Linting
make lint

# Bash 3.0+ compatibility (must return no results)
grep -rn '\[\[' src/ || true
grep -rn 'declare -A' src/ || true

# CI status
./bashunit tests/ # all green
make sa && make lint # static analysis + editorconfig
gh run list --limit 3 --branch main
```

If ANY check fails, stop and report the issue. Do NOT proceed to release.

### 2. Confirm with user
Stop and report if anything fails. Don't release on a red main.

Show a summary:
- Version: current → new (from `$ARGUMENTS` or auto-incremented)
- Key changes from CHANGELOG Unreleased section (abbreviated)
- All checks passed
### 2. Pick the version

Ask the user to confirm before proceeding.
`$ARGUMENTS` overrides; otherwise the script auto-increments the minor.
Bump by the Unreleased section: a `### Added`/feat → minor, only `### Fixed` →
patch. Confirm the version with the user before publishing.

### 3. Execute release
### 3. Preview, then publish

```bash
./release.sh $ARGUMENTS
./release.sh <version> --dry-run # preview; changes nothing
./release.sh <version> --force # publish (non-interactive)
```

If `$ARGUMENTS` is empty, run `./release.sh` (auto-increments minor version).
Notes:
- `--dry-run` release notes look "off" (they show the previous version's
section) because the CHANGELOG isn't actually rewritten in a dry run. The
real run converts `## Unreleased` → `## [<version>]` first, so the published
notes are correct. Not a bug.
- Tagging is gpgsign-safe: `release::create_tags` makes annotated, `-m`
tags (signed when `tag.gpgsign=true`) and pins `v0` to the release commit
(`^{}`). No manual tagging needed.
- npm publishes automatically via `.github/workflows/npm-publish.yml` on the
GitHub `release: published` event.

The script handles everything interactively: version bumps, build, commit, tag, GitHub release, and docs deployment.

**Important:** The script uses interactive prompts (`read`) that may be skipped when run from Claude. If the script skips the commit, tag, push, or GitHub release steps, complete them manually:
### 4. Verify the published artifacts

```bash
# Commit the release changes
git add CHANGELOG.md bashunit install.sh package.json
git commit -m "chore(release): <version>"

# Tag
git tag -a <version> -m "<version>"

# Push
git push origin main --tags

# Create GitHub release with BOTH binary and checksum as assets
gh release create <version> bin/bashunit bin/checksum \
--title "<version>" \
--notes-file /tmp/bashunit-release-notes-<version>.md

# Update latest branch for docs deployment
git checkout latest && git rebase <version> \
&& git push origin latest --force && git checkout main
gh release view <version> # assets: bin/bashunit + bin/checksum
gh run list --workflow npm-publish.yml --limit 1
git log --oneline -1 origin/latest # latest branch advanced (docs deploy)
```

### 4. Post-release
Confirm the npm version and the install.sh checksum match, then report the
release URL.

After the script completes, verify:
```bash
git log --oneline -1
git tag --list --sort=-v:refname | head -1
```
## Recovery

Report the release URL to the user.
`./release.sh --rollback` restores files from the most recent backup if a run
fails mid-way.

## Example Usage

```
/release
/release 0.34.0
/release 1.0.0
/release 0.40.0
```
34 changes: 28 additions & 6 deletions release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,8 @@ function release::sandbox::run() {
git add "${RELEASE_FILES[@]}"
git commit -m "release: $VERSION" -n
release::log_success "Created commit"
git tag "$VERSION"
# Annotated + inline message: gpgsign-safe and never opens an editor.
git tag -a -m "$VERSION" "$VERSION"
release::log_success "Created tag $VERSION"

# Generate release notes
Expand Down Expand Up @@ -575,6 +576,30 @@ function release::major_tag() {
echo "v$major"
}

##
# Create the version tag and (re)point the floating major tag at the release.
#
# Both tags are annotated with an inline message. This is gpgsign-safe: under
# `tag.gpgsign=true` git signs the annotated tag, and the `-m` message means
# git never opens an editor (a plain `git tag NAME` would abort with
# "Please supply the message" in a non-interactive/CI run). The major tag is
# moved with `-f` and pinned to the dereferenced commit (`^{}`) so it tracks
# the release commit rather than the version tag object.
#
# Arguments: $1 - new version (e.g. 0.40.0)
# Outputs: the major tag name (e.g. v0) on stdout
##
function release::create_tags() {
local new_version=$1
local major_tag
major_tag=$(release::major_tag "$new_version")

git tag -a -m "$new_version" "$new_version"
git tag -f -a -m "$major_tag" "$major_tag" "${new_version}^{}"

echo "$major_tag"
}

function release::validate_semver() {
local version=$1
if ! regex_match "$version" '^[0-9]+\.[0-9]+\.[0-9]+$'; then
Expand Down Expand Up @@ -871,12 +896,9 @@ function release::git_commit_and_tag() {
git commit -m "release: $new_version" -n
release::log_success "Created commit"

git tag "$new_version"
release::log_success "Created tag $new_version"

local major_tag
major_tag=$(release::major_tag "$new_version")
git tag -f "$major_tag" "$new_version"
major_tag=$(release::create_tags "$new_version")
release::log_success "Created tag $new_version"
release::log_success "Moved major tag $major_tag -> $new_version"

if release::confirm_action "Do you want to push commit and tag to origin?"; then
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/release_utilities_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,68 @@ function test_major_tag_returns_v_prefixed_major_for_zero() {
function test_major_tag_returns_v_prefixed_major_for_one() {
assert_same "v1" "$(release::major_tag "1.2.3")"
}

##########################
# create_tags tests
##########################

# Builds a throwaway git repo with one commit and returns its path.
# tag.gpgsign is left false so the test needs no GPG key, while still
# exercising the annotated/-m behavior that keeps tagging gpgsign-safe.
function _create_tags_setup_repo() {
local repo
repo="$(mktemp -d)"
(
cd "$repo" || exit 1
git init -q
git config user.email "test@bashunit.dev"
git config user.name "bashunit test"
git config commit.gpgsign false
git config tag.gpgsign false
git commit -q --allow-empty -m "initial"
)
echo "$repo"
}

function test_create_tags_creates_an_annotated_version_tag() {
local repo origin
repo="$(_create_tags_setup_repo)"
origin="$(pwd)"

cd "$repo" || return 1
release::create_tags "0.40.0" >/dev/null
# An annotated tag is a tag object; a lightweight tag resolves to a commit.
assert_same "tag" "$(git cat-file -t 0.40.0)"
assert_contains "0.40.0" "$(git tag -l --format='%(contents)' 0.40.0)"

cd "$origin" || return 1
rm -rf "$repo"
}

function test_create_tags_moves_major_tag_to_release_commit() {
local repo origin
repo="$(_create_tags_setup_repo)"
origin="$(pwd)"

cd "$repo" || return 1
release::create_tags "0.40.0" >/dev/null
assert_same "tag" "$(git cat-file -t v0)"
# v0 must point at the release commit, not the version tag object.
assert_same "$(git rev-parse HEAD)" "$(git rev-parse 'v0^{commit}')"

cd "$origin" || return 1
rm -rf "$repo"
}

function test_create_tags_returns_major_tag_name() {
local repo origin result
repo="$(_create_tags_setup_repo)"
origin="$(pwd)"

cd "$repo" || return 1
result="$(release::create_tags '0.40.0')"
assert_same "v0" "$result"

cd "$origin" || return 1
rm -rf "$repo"
}
Loading