Skip to content

Commit 09f35e1

Browse files
authored
fix(release): create signed annotated tags safely under tag.gpgsign (#727)
1 parent 8e8e5bf commit 09f35e1

3 files changed

Lines changed: 129 additions & 70 deletions

File tree

.claude/skills/release/SKILL.md

Lines changed: 36 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@ allowed-tools: Bash, Read, Grep, Glob
88

99
# Release
1010

11-
Run pre-release checks and create a new bashunit release.
12-
13-
## Arguments
14-
15-
- `$ARGUMENTS` - Version number (optional, e.g., `0.34.0`). If omitted, auto-increments the minor version.
11+
Thin reminder around `./release.sh`. The release script owns the whole
12+
end-to-end flow (version bumps, build, checksum, CHANGELOG, commit, signed
13+
tags, push, GitHub release, `latest` branch). Don't reimplement those steps
14+
here — fix `release.sh` if something is missing.
1615

1716
## Current State
1817

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

24-
## Instructions
23+
## Steps
2524

26-
### 1. Pre-flight validation
27-
28-
Run these checks and report pass/fail for each:
25+
### 1. Pre-flight
2926

3027
```bash
31-
# Tests
32-
./bashunit tests/
33-
34-
# Static analysis
35-
make sa
36-
37-
# Linting
38-
make lint
39-
40-
# Bash 3.0+ compatibility (must return no results)
41-
grep -rn '\[\[' src/ || true
42-
grep -rn 'declare -A' src/ || true
43-
44-
# CI status
28+
./bashunit tests/ # all green
29+
make sa && make lint # static analysis + editorconfig
4530
gh run list --limit 3 --branch main
4631
```
4732

48-
If ANY check fails, stop and report the issue. Do NOT proceed to release.
49-
50-
### 2. Confirm with user
33+
Stop and report if anything fails. Don't release on a red main.
5134

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

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

59-
### 3. Execute release
41+
### 3. Preview, then publish
6042

6143
```bash
62-
./release.sh $ARGUMENTS
44+
./release.sh <version> --dry-run # preview; changes nothing
45+
./release.sh <version> --force # publish (non-interactive)
6346
```
6447

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

67-
The script handles everything interactively: version bumps, build, commit, tag, GitHub release, and docs deployment.
68-
69-
**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:
59+
### 4. Verify the published artifacts
7060

7161
```bash
72-
# Commit the release changes
73-
git add CHANGELOG.md bashunit install.sh package.json
74-
git commit -m "chore(release): <version>"
75-
76-
# Tag
77-
git tag -a <version> -m "<version>"
78-
79-
# Push
80-
git push origin main --tags
81-
82-
# Create GitHub release with BOTH binary and checksum as assets
83-
gh release create <version> bin/bashunit bin/checksum \
84-
--title "<version>" \
85-
--notes-file /tmp/bashunit-release-notes-<version>.md
86-
87-
# Update latest branch for docs deployment
88-
git checkout latest && git rebase <version> \
89-
&& git push origin latest --force && git checkout main
62+
gh release view <version> # assets: bin/bashunit + bin/checksum
63+
gh run list --workflow npm-publish.yml --limit 1
64+
git log --oneline -1 origin/latest # latest branch advanced (docs deploy)
9065
```
9166

92-
### 4. Post-release
67+
Confirm the npm version and the install.sh checksum match, then report the
68+
release URL.
9369

94-
After the script completes, verify:
95-
```bash
96-
git log --oneline -1
97-
git tag --list --sort=-v:refname | head -1
98-
```
70+
## Recovery
9971

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

10275
## Example Usage
10376

10477
```
10578
/release
106-
/release 0.34.0
107-
/release 1.0.0
79+
/release 0.40.0
10880
```

release.sh

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,8 @@ function release::sandbox::run() {
525525
git add "${RELEASE_FILES[@]}"
526526
git commit -m "release: $VERSION" -n
527527
release::log_success "Created commit"
528-
git tag "$VERSION"
528+
# Annotated + inline message: gpgsign-safe and never opens an editor.
529+
git tag -a -m "$VERSION" "$VERSION"
529530
release::log_success "Created tag $VERSION"
530531

531532
# Generate release notes
@@ -575,6 +576,30 @@ function release::major_tag() {
575576
echo "v$major"
576577
}
577578

579+
##
580+
# Create the version tag and (re)point the floating major tag at the release.
581+
#
582+
# Both tags are annotated with an inline message. This is gpgsign-safe: under
583+
# `tag.gpgsign=true` git signs the annotated tag, and the `-m` message means
584+
# git never opens an editor (a plain `git tag NAME` would abort with
585+
# "Please supply the message" in a non-interactive/CI run). The major tag is
586+
# moved with `-f` and pinned to the dereferenced commit (`^{}`) so it tracks
587+
# the release commit rather than the version tag object.
588+
#
589+
# Arguments: $1 - new version (e.g. 0.40.0)
590+
# Outputs: the major tag name (e.g. v0) on stdout
591+
##
592+
function release::create_tags() {
593+
local new_version=$1
594+
local major_tag
595+
major_tag=$(release::major_tag "$new_version")
596+
597+
git tag -a -m "$new_version" "$new_version"
598+
git tag -f -a -m "$major_tag" "$major_tag" "${new_version}^{}"
599+
600+
echo "$major_tag"
601+
}
602+
578603
function release::validate_semver() {
579604
local version=$1
580605
if ! regex_match "$version" '^[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -871,12 +896,9 @@ function release::git_commit_and_tag() {
871896
git commit -m "release: $new_version" -n
872897
release::log_success "Created commit"
873898

874-
git tag "$new_version"
875-
release::log_success "Created tag $new_version"
876-
877899
local major_tag
878-
major_tag=$(release::major_tag "$new_version")
879-
git tag -f "$major_tag" "$new_version"
900+
major_tag=$(release::create_tags "$new_version")
901+
release::log_success "Created tag $new_version"
880902
release::log_success "Moved major tag $major_tag -> $new_version"
881903

882904
if release::confirm_action "Do you want to push commit and tag to origin?"; then

tests/unit/release_utilities_test.sh

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,68 @@ function test_major_tag_returns_v_prefixed_major_for_zero() {
340340
function test_major_tag_returns_v_prefixed_major_for_one() {
341341
assert_same "v1" "$(release::major_tag "1.2.3")"
342342
}
343+
344+
##########################
345+
# create_tags tests
346+
##########################
347+
348+
# Builds a throwaway git repo with one commit and returns its path.
349+
# tag.gpgsign is left false so the test needs no GPG key, while still
350+
# exercising the annotated/-m behavior that keeps tagging gpgsign-safe.
351+
function _create_tags_setup_repo() {
352+
local repo
353+
repo="$(mktemp -d)"
354+
(
355+
cd "$repo" || exit 1
356+
git init -q
357+
git config user.email "test@bashunit.dev"
358+
git config user.name "bashunit test"
359+
git config commit.gpgsign false
360+
git config tag.gpgsign false
361+
git commit -q --allow-empty -m "initial"
362+
)
363+
echo "$repo"
364+
}
365+
366+
function test_create_tags_creates_an_annotated_version_tag() {
367+
local repo origin
368+
repo="$(_create_tags_setup_repo)"
369+
origin="$(pwd)"
370+
371+
cd "$repo" || return 1
372+
release::create_tags "0.40.0" >/dev/null
373+
# An annotated tag is a tag object; a lightweight tag resolves to a commit.
374+
assert_same "tag" "$(git cat-file -t 0.40.0)"
375+
assert_contains "0.40.0" "$(git tag -l --format='%(contents)' 0.40.0)"
376+
377+
cd "$origin" || return 1
378+
rm -rf "$repo"
379+
}
380+
381+
function test_create_tags_moves_major_tag_to_release_commit() {
382+
local repo origin
383+
repo="$(_create_tags_setup_repo)"
384+
origin="$(pwd)"
385+
386+
cd "$repo" || return 1
387+
release::create_tags "0.40.0" >/dev/null
388+
assert_same "tag" "$(git cat-file -t v0)"
389+
# v0 must point at the release commit, not the version tag object.
390+
assert_same "$(git rev-parse HEAD)" "$(git rev-parse 'v0^{commit}')"
391+
392+
cd "$origin" || return 1
393+
rm -rf "$repo"
394+
}
395+
396+
function test_create_tags_returns_major_tag_name() {
397+
local repo origin result
398+
repo="$(_create_tags_setup_repo)"
399+
origin="$(pwd)"
400+
401+
cd "$repo" || return 1
402+
result="$(release::create_tags '0.40.0')"
403+
assert_same "v0" "$result"
404+
405+
cd "$origin" || return 1
406+
rm -rf "$repo"
407+
}

0 commit comments

Comments
 (0)