Skip to content

Commit c934b06

Browse files
chore: add scripts/release.sh to automate releases
Single maintainer command bumps skills/groove/SKILL.md, promotes the CHANGELOG ## [Unreleased] section to a dated version heading, commits, tags, pushes, and publishes the GitHub Release with notes extracted from the changelog — keeping SKILL.md, the tag, and the release in lockstep. Adds --dry-run, release-only recovery, and backfill modes. Adopts a ## [Unreleased] changelog convention and rewrites the CONTRIBUTING 'Publish release' section to use the script (manual steps kept as a fallback).
1 parent ad69ca5 commit c934b06

3 files changed

Lines changed: 301 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to groove will be documented in this file.
44

5+
Unreleased changes are drafted under `## [Unreleased]`; `scripts/release.sh` promotes that section to a dated version heading on release.
6+
7+
## [Unreleased]
8+
9+
### 🔧 Changes
10+
11+
- **Release tooling** — added `scripts/release.sh`, a single maintainer command that bumps `skills/groove/SKILL.md`, promotes the `## [Unreleased]` changelog section to a dated version heading, commits, tags, pushes, and publishes the GitHub Release with notes extracted from the changelog — keeping SKILL.md, the git tag, and the release in lockstep. Includes `--dry-run`, a `release-only` recovery mode, and a `backfill` mode for tags missing a release.
12+
513
## [0.19.1] - 2026-06-03
614

715
### 🐞 Fixes

CONTRIBUTING.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,23 +77,39 @@ groove uses [semantic versioning](https://semver.org). The version lives in two
7777

7878
### How to bump
7979

80-
1. Update `metadata.version` in `skills/groove/SKILL.md`
81-
2. Add an entry to `CHANGELOG.md`
82-
3. If a migration is needed, write it (see below)
80+
1. Draft your changelog entries under the `## [Unreleased]` heading in `CHANGELOG.md` as you work (use the emoji subsections — `### ✨ New Skills`, `### 🔧 Changes`, `### 🐞 Fixes`, `### ⚠️ Breaking Changes`).
81+
2. If a migration is needed, write it (see below).
82+
3. Run the release script (next section) — it bumps `metadata.version`, dates the changelog, tags, and publishes in one step.
8383

8484
### Publish release
8585

86-
`groove check`, `groove prime`, and `groove update` use GitHub's `releases/latest` API as the source of truth for "latest version". A tag alone does not create a release; without a release, version checks can be wrong or fail. After bumping, publish both a tag and a GitHub Release:
86+
`groove check`, `groove prime`, and `groove update` use GitHub's `releases/latest` API as the source of truth for "latest version". A tag alone does not create a release; without a release, version checks can be wrong or fail. The three version surfaces — `skills/groove/SKILL.md` `metadata.version`, the git tag, and the GitHub Release — must stay in lockstep.
8787

88-
1. Commit and push.
89-
2. Create and push the tag: `git tag vX.Y.Z` then `git push origin vX.Y.Z`.
90-
3. Create a GitHub Release for that tag: **Releases → Draft a new release**, choose the tag, paste the relevant CHANGELOG section as release notes, publish. Or: `gh release create vX.Y.Z --notes "<paste from CHANGELOG>"`.
88+
**Use the release script** — it does all of it from the repo root:
9189

92-
Once the GitHub Release is the `releases/latest` target, consumer projects pick it up via `npx skills add andreadellacorte/groove` / `groove update`. This repo keeps no checked-in install snapshot, so there is nothing further to sync here.
90+
```bash
91+
scripts/release.sh patch # or: minor | major | X.Y.Z
92+
scripts/release.sh patch --dry-run # preview the bump, changelog promotion, and notes — changes nothing
93+
```
94+
95+
It runs preconditions (clean tree, on `main`, `gh` authenticated, tag not already taken), bumps `metadata.version`, promotes `## [Unreleased]` → `## [X.Y.Z] - <today>` (and re-seeds an empty `## [Unreleased]`), commits `chore(release): vX.Y.Z`, pushes, tags, and creates the GitHub Release with notes extracted from the changelog. The highest version is published last, so it becomes `releases/latest` and consumer projects pick it up via `npx skills add andreadellacorte/groove` / `groove update`.
96+
97+
Recovery and backfill:
98+
99+
```bash
100+
scripts/release.sh release-only # tag was pushed but the GitHub Release failed — publish it from the existing tag
101+
scripts/release.sh backfill # create releases for any tags that never got one, ascending (--dry-run to preview)
102+
```
103+
104+
<details>
105+
<summary>Manual fallback (if the script can't run)</summary>
93106

94-
Release notes: copy from `CHANGELOG.md` from `## [X.Y.Z]` down to (but not including) the next `## [`.
107+
1. Update `metadata.version` in `skills/groove/SKILL.md`; date the `## [Unreleased]` heading to `## [X.Y.Z] - <today>`.
108+
2. Commit and push. Create and push the tag: `git tag vX.Y.Z` then `git push origin vX.Y.Z`.
109+
3. Create the GitHub Release: `gh release create vX.Y.Z --notes "<paste from CHANGELOG>"`. Release notes are the lines from `## [X.Y.Z]` down to (but not including) the next `## [`.
95110

96-
**Backfilling existing tags:** If you have tags that never got a GitHub Release, create a release for each: list tags without a release (e.g. compare `gh release list` with `git tag -l 'v*'`), then for each missing one run `gh release create <tag> --notes "<paste from CHANGELOG>"` or use the Releases UI. Do them in ascending version order (oldest first) so the highest version is published last and becomes `releases/latest`.
111+
For backfilling: compare `gh release list` with `git tag -l 'v*'`, then `gh release create <tag> --notes "<paste from CHANGELOG>"` for each missing one, in ascending order.
112+
</details>
97113

98114
---
99115

scripts/release.sh

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
#!/usr/bin/env bash
2+
# groove release — one command to bump, changelog, commit, tag, and publish.
3+
#
4+
# Keeps the three version surfaces in lockstep so they can never drift:
5+
# - skills/groove/SKILL.md (metadata.version)
6+
# - git tag (vX.Y.Z)
7+
# - GitHub Release (releases/latest — source of truth for groove check/update)
8+
#
9+
# Usage:
10+
# scripts/release.sh <patch|minor|major|X.Y.Z> [--dry-run] [--allow-branch]
11+
# scripts/release.sh release-only [--dry-run] # publish the release for an already-pushed tag (recovery)
12+
# scripts/release.sh backfill [--dry-run] # create releases for tags that lack one, ascending
13+
#
14+
# Maintainers only: run from the andreadellacorte/groove repo root. See CONTRIBUTING.md.
15+
16+
set -euo pipefail
17+
18+
REPO="andreadellacorte/groove"
19+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20+
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
21+
SKILL="${ROOT}/skills/groove/SKILL.md"
22+
CHANGELOG="${ROOT}/CHANGELOG.md"
23+
24+
die() { echo "release: $*" >&2; exit 1; }
25+
info() { echo "release: $*"; }
26+
27+
# ---- argument parsing -------------------------------------------------------
28+
DRY_RUN=0
29+
ALLOW_BRANCH=0
30+
MODE=""
31+
ARG=""
32+
for a in "$@"; do
33+
case "$a" in
34+
--dry-run) DRY_RUN=1 ;;
35+
--allow-branch) ALLOW_BRANCH=1 ;;
36+
backfill|release-only) MODE="$a" ;;
37+
patch|minor|major) MODE="bump"; ARG="$a" ;;
38+
[0-9]*.[0-9]*.[0-9]*) MODE="explicit"; ARG="$a" ;;
39+
-*) die "unknown flag: $a" ;;
40+
*) die "unknown argument: $a" ;;
41+
esac
42+
done
43+
[ -n "$MODE" ] || die "usage: release.sh <patch|minor|major|X.Y.Z|release-only|backfill> [--dry-run] [--allow-branch]"
44+
45+
run() {
46+
# Execute, or just print, depending on dry-run.
47+
if [ "$DRY_RUN" -eq 1 ]; then echo " [dry-run] $*"; else "$@"; fi
48+
}
49+
50+
# ---- shared preconditions ---------------------------------------------------
51+
[ -f "$SKILL" ] || die "cannot find skills/groove/SKILL.md — run from the groove repo root"
52+
[ -d "${ROOT}/.git" ] || die "not a git repo — run from the groove repo root"
53+
[ -f "$CHANGELOG" ] || die "cannot find CHANGELOG.md"
54+
command -v git >/dev/null || die "git not available"
55+
command -v gh >/dev/null || die "gh (GitHub CLI) not available"
56+
command -v python3 >/dev/null || die "python3 not available"
57+
gh auth status >/dev/null 2>&1 || die "gh is not authenticated — run: gh auth login"
58+
59+
cd "$ROOT"
60+
61+
current=$(sed -n "s/^[[:space:]]*version:[[:space:]]*[\"']\([^\"']*\)[\"'].*/\1/p" "$SKILL" | head -1)
62+
[ -n "$current" ] || die "could not read metadata.version from skills/groove/SKILL.md"
63+
64+
# ---- helpers (python3 for semver + changelog surgery) -----------------------
65+
semver_next() { # <current> <patch|minor|major> -> next version
66+
python3 - "$1" "$2" <<'PY'
67+
import sys
68+
cur, kind = sys.argv[1], sys.argv[2]
69+
a, b, c = (int(x) for x in cur.split("."))
70+
if kind == "patch": c += 1
71+
elif kind == "minor": b, c = b + 1, 0
72+
elif kind == "major": a, b, c = a + 1, 0, 0
73+
print(f"{a}.{b}.{c}")
74+
PY
75+
}
76+
77+
semver_gt() { # returns 0 if $1 > $2
78+
python3 - "$1" "$2" <<'PY'
79+
import sys
80+
def t(v): return tuple(int(x) for x in v.split("."))
81+
sys.exit(0 if t(sys.argv[1]) > t(sys.argv[2]) else 1)
82+
PY
83+
}
84+
85+
extract_notes() { # <version> -> prints the CHANGELOG section body for that version
86+
VERSION="$1" python3 - "$CHANGELOG" <<'PY'
87+
import os, re, sys
88+
version = os.environ["VERSION"]
89+
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
90+
out, capture = [], False
91+
for ln in lines:
92+
m = re.match(r"^## \[([^\]]+)\]", ln)
93+
if m:
94+
if capture: # reached the next section -> stop
95+
break
96+
capture = (m.group(1) == version)
97+
continue
98+
if capture:
99+
out.append(ln)
100+
print("\n".join(out).strip())
101+
PY
102+
}
103+
104+
promote_unreleased() { # <version> <date> -> rewrites CHANGELOG in place; fails if no usable notes
105+
VERSION="$1" RELDATE="$2" python3 - "$CHANGELOG" <<'PY'
106+
import os, re, sys
107+
version, date, path = os.environ["VERSION"], os.environ["RELDATE"], sys.argv[1]
108+
text = open(path, encoding="utf-8").read()
109+
lines = text.splitlines()
110+
111+
def section_body(idx):
112+
body = []
113+
for ln in lines[idx+1:]:
114+
if re.match(r"^## \[", ln):
115+
break
116+
body.append(ln)
117+
return body
118+
119+
def has_content(body):
120+
# Non-empty if any line carries text beyond blank lines / subsection headers.
121+
return any(ln.strip() and not ln.lstrip().startswith("###") for ln in body)
122+
123+
unrel = next((i for i, ln in enumerate(lines)
124+
if re.match(r"^## \[Unreleased\]", ln, re.I)), None)
125+
existing = next((i for i, ln in enumerate(lines)
126+
if re.match(rf"^## \[{re.escape(version)}\]", ln)), None)
127+
128+
if unrel is not None:
129+
if not has_content(section_body(unrel)):
130+
sys.exit("release: ## [Unreleased] has no notes — add changelog entries before releasing")
131+
lines[unrel] = f"## [Unreleased]\n\n## [{version}] - {date}"
132+
elif existing is not None:
133+
# No Unreleased section, but the version header already exists: stamp/normalise its date.
134+
lines[existing] = f"## [{version}] - {date}"
135+
if not has_content(section_body(existing)):
136+
sys.exit(f"release: ## [{version}] has no notes — add changelog entries before releasing")
137+
else:
138+
sys.exit(f"release: no ## [Unreleased] or ## [{version}] section in CHANGELOG.md — add notes first")
139+
140+
open(path, "w", encoding="utf-8").write("\n".join(lines) + "\n")
141+
PY
142+
}
143+
144+
# ---- mode: backfill ---------------------------------------------------------
145+
if [ "$MODE" = "backfill" ]; then
146+
git fetch --tags --quiet || true
147+
released=$(gh release list -L 500 --repo "$REPO" 2>/dev/null | awk '{print $1}' | sort)
148+
missing=$(comm -23 <(git tag -l 'v*' | sort) <(echo "$released") | sort -V)
149+
[ -n "$missing" ] || { info "no tags missing a release — nothing to backfill"; exit 0; }
150+
info "tags missing a GitHub release (ascending):"
151+
while IFS= read -r tag; do
152+
[ -n "$tag" ] || continue
153+
ver="${tag#v}"
154+
notes=$(extract_notes "$ver")
155+
[ -n "$notes" ] || notes="Release $tag"
156+
echo " - $tag"
157+
run gh release create "$tag" --repo "$REPO" --title "$tag" --notes "$notes"
158+
done <<< "$missing"
159+
info "backfill complete"
160+
exit 0
161+
fi
162+
163+
# ---- determine target version ----------------------------------------------
164+
if [ "$MODE" = "release-only" ]; then
165+
target="$current" # publish the release for the current SKILL.md version's tag
166+
else
167+
if [ "$MODE" = "bump" ]; then
168+
target=$(semver_next "$current" "$ARG")
169+
else
170+
target="$ARG"
171+
fi
172+
semver_gt "$target" "$current" || die "target v$target is not greater than current v$current"
173+
fi
174+
tag="v${target}"
175+
reldate=$(date +%F)
176+
177+
# ---- mode: release-only (recovery) -----------------------------------------
178+
if [ "$MODE" = "release-only" ]; then
179+
git rev-parse "$tag" >/dev/null 2>&1 || die "tag $tag does not exist locally — run a normal release instead"
180+
if gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then
181+
die "release $tag already exists"
182+
fi
183+
notes=$(extract_notes "$target")
184+
[ -n "$notes" ] || die "no CHANGELOG notes found for $target"
185+
info "publishing release $tag from existing tag"
186+
echo "----- notes -----"; echo "$notes"; echo "-----------------"
187+
run gh release create "$tag" --repo "$REPO" --title "$tag" --notes "$notes"
188+
info "done"
189+
exit 0
190+
fi
191+
192+
# ---- full release: preconditions -------------------------------------------
193+
# In --dry-run these warn instead of aborting, so a preview works at any time.
194+
precond() { if [ "$DRY_RUN" -eq 1 ]; then echo "release: [dry-run] would abort: $*" >&2; else die "$*"; fi; }
195+
196+
branch=$(git rev-parse --abbrev-ref HEAD)
197+
if [ "$branch" != "main" ] && [ "$ALLOW_BRANCH" -eq 0 ]; then
198+
precond "on branch '$branch', not 'main' — pass --allow-branch to override"
199+
fi
200+
if ! git diff --quiet || ! git diff --cached --quiet; then
201+
precond "working tree has uncommitted changes — commit or stash first"
202+
fi
203+
git rev-parse "$tag" >/dev/null 2>&1 && precond "tag $tag already exists locally — use 'release-only' to publish it"
204+
if git ls-remote --exit-code --tags origin "$tag" >/dev/null 2>&1; then
205+
precond "tag $tag already exists on origin"
206+
fi
207+
git fetch --quiet origin "$branch" || true
208+
if [ -n "$(git rev-list "HEAD..origin/${branch}" 2>/dev/null)" ]; then
209+
precond "local '$branch' is behind origin/${branch} — pull first"
210+
fi
211+
212+
info "releasing v$current -> v$target (tag $tag, $reldate)"
213+
214+
# ---- step 1: bump SKILL.md --------------------------------------------------
215+
if [ "$DRY_RUN" -eq 1 ]; then
216+
echo " [dry-run] bump skills/groove/SKILL.md: version: \"$current\" -> \"$target\""
217+
else
218+
python3 - "$SKILL" "$current" "$target" <<'PY'
219+
import re, sys
220+
path, old, new = sys.argv[1], sys.argv[2], sys.argv[3]
221+
text = open(path, encoding="utf-8").read()
222+
new_text, n = re.subn(rf'(version:\s*["\']){re.escape(old)}(["\'])', rf'\g<1>{new}\g<2>', text, count=1)
223+
if n != 1:
224+
sys.exit(f"release: expected exactly one version: \"{old}\" line in SKILL.md, found {n}")
225+
open(path, "w", encoding="utf-8").write(new_text)
226+
PY
227+
fi
228+
229+
# ---- step 2: changelog promote + extract notes ------------------------------
230+
if [ "$DRY_RUN" -eq 1 ]; then
231+
echo " [dry-run] promote CHANGELOG: ## [Unreleased] -> ## [$target] - $reldate"
232+
notes=$(extract_notes "$target")
233+
if [ -z "$notes" ]; then
234+
# Dry-run preview when notes live under an Unreleased heading.
235+
notes=$(VERSION="Unreleased" python3 -c '
236+
import os,re,sys
237+
v=os.environ["VERSION"]; lines=open(sys.argv[1],encoding="utf-8").read().splitlines()
238+
out=[];cap=False
239+
for ln in lines:
240+
m=re.match(r"^## \[([^\]]+)\]",ln)
241+
if m:
242+
if cap: break
243+
cap=(m.group(1).lower()==v.lower()); continue
244+
if cap: out.append(ln)
245+
print("\n".join(out).strip())' "$CHANGELOG")
246+
fi
247+
else
248+
promote_unreleased "$target" "$reldate"
249+
notes=$(extract_notes "$target")
250+
[ -n "$notes" ] || die "extracted empty release notes for $target after promotion"
251+
fi
252+
echo "----- release notes -----"; echo "${notes:-(none)}"; echo "-------------------------"
253+
254+
# ---- steps 3-5: commit, tag, push, release ----------------------------------
255+
run git add "$SKILL" "$CHANGELOG"
256+
run git commit -m "chore(release): $tag"
257+
run git push origin "$branch"
258+
run git tag "$tag"
259+
run git push origin "$tag"
260+
run gh release create "$tag" --repo "$REPO" --title "$tag" --notes "$notes"
261+
262+
if [ "$DRY_RUN" -eq 1 ]; then
263+
info "dry-run complete — no changes made"
264+
else
265+
url=$(gh release view "$tag" --repo "$REPO" --json url -q .url 2>/dev/null || echo "")
266+
info "released $tag ${url}"
267+
fi

0 commit comments

Comments
 (0)