From 7007b68f05ac142ec8bfe60e40a1ba1719a46a98 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 16 Jun 2026 20:41:47 +0200 Subject: [PATCH] fix(release): create signed annotated tags safely under tag.gpgsign A plain `git tag ` aborts with "Please supply the message" when `tag.gpgsign=true` and no editor is available (CI / non-interactive runs). Extract tagging into `release::create_tags`, which always creates annotated `-m` tags (signed when gpgsign is on) and pins the floating major tag to the release commit via `^{}`. Add regression tests and slim the /release skill to a thin reminder now that the flow is fully handled in release.sh. --- .claude/skills/release/SKILL.md | 100 ++++++++++----------------- release.sh | 34 +++++++-- tests/unit/release_utilities_test.sh | 65 +++++++++++++++++ 3 files changed, 129 insertions(+), 70 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index f75b3676..36c526ba 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -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 @@ -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 --dry-run # preview; changes nothing +./release.sh --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` → `## []` 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): " - -# Tag -git tag -a -m "" - -# Push -git push origin main --tags - -# Create GitHub release with BOTH binary and checksum as assets -gh release create bin/bashunit bin/checksum \ - --title "" \ - --notes-file /tmp/bashunit-release-notes-.md - -# Update latest branch for docs deployment -git checkout latest && git rebase \ - && git push origin latest --force && git checkout main +gh release view # 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 ``` diff --git a/release.sh b/release.sh index 3b16a52a..f7f54aa0 100755 --- a/release.sh +++ b/release.sh @@ -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 @@ -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 @@ -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 diff --git a/tests/unit/release_utilities_test.sh b/tests/unit/release_utilities_test.sh index c8c93d99..6c83c8af 100644 --- a/tests/unit/release_utilities_test.sh +++ b/tests/unit/release_utilities_test.sh @@ -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" +}