Skip to content

Commit 84778c7

Browse files
Auto semver and release notes generation for releases (#696)
* add automation for deciding next version and release notes using AI * improve prompt and limit releasing to main branch * add cleanup of temp release notes files * first round of improvments * apply second round of reviews * clean dead comments
1 parent 03ac21f commit 84778c7

5 files changed

Lines changed: 287 additions & 7 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,23 @@ jobs:
123123
with:
124124
go-version: ${{ env.GO_VERSION }}
125125

126+
# Use release notes from the tag body when present (set by interactive `make release` or `make release tag=vX.Y.Z` with dist/release_notes.md). Otherwise GoReleaser uses its default changelog.
127+
- name: Get release notes from tag
128+
id: get-tag-notes
129+
run: |
130+
TAG="${GITHUB_REF#refs/tags/}"
131+
BODY=$(git tag -l --format='%(contents:body)' "$TAG")
132+
if [ -n "$BODY" ]; then
133+
mkdir -p dist
134+
printf '%s' "$BODY" > dist/release_notes.md
135+
echo "args=--release-notes=dist/release_notes.md" >> $GITHUB_OUTPUT
136+
fi
137+
126138
- name: Run GoReleaser
127139
uses: goreleaser/goreleaser-action@v5
128140
with:
129141
version: latest
130-
args: release --clean
142+
args: release --clean ${{ steps.get-tag-notes.outputs.args }}
131143
env:
132144
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
133145
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}

Makefile

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,35 @@ helm-docs: helm-lint
174174
@cd charts/k8s-reporter && docker run --rm --volume "$(PWD):/helm-docs" jnorwood/helm-docs:latest --template-files README.md.gotmpl,_templates.gotmpl --output-file README.md
175175
@cd charts/k8s-reporter && docker run --rm --volume "$(PWD):/helm-docs" jnorwood/helm-docs:latest --template-files README.md.gotmpl,_templates.gotmpl --output-file ../../docs.kosli.com/content/helm/_index.md
176176

177+
# Suggest next semver and changelog using Claude.
178+
# Writes changelog to dist/release_notes.md for use with goreleaser --release-notes.
179+
# Requires: jq, curl, op (1Password CLI). API key from 1Password via op.
180+
# Usage: make suggest-version-ai [BASE_REF=v1.2.3]
181+
suggest-version-ai:
182+
@command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1)
183+
@command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1)
184+
@bin/suggest-version-ai.sh $(BASE_REF) -o dist/release_notes.md
185+
186+
# Release: without tag → suggest version + changelog, then interactive edit & confirm, then tag and push.
187+
# With tag → escape hatch: create annotated tag (body = dist/release_notes.md if present), push. No AI, no prompt.
188+
# Release notes are carried in the tag message so GitHub Actions can pass them to GoReleaser.
177189
release:
178-
@git remote update
179-
@git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && return 1)
180-
git tag -a $(tag) -m"$(tag)"
181-
git push origin $(tag)
190+
@current=$$(git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD); \
191+
if [ "$$current" != "main" ]; then echo "ERROR: release must be run from main branch (current: $$current)"; exit 1; fi; \
192+
if [ -z "$(tag)" ]; then \
193+
command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1); \
194+
command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1); \
195+
bin/suggest-version-ai.sh -o dist/release_notes.md; \
196+
if [ ! -f dist/suggested_version ]; then \
197+
echo "Suggestion failed or no previous tag. Use: make release tag=vX.Y.Z"; exit 1; \
198+
fi; \
199+
bin/release-interactive.sh; \
200+
else \
201+
git remote update; \
202+
git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && exit 1); \
203+
([ -f dist/release_notes.md ] && git tag -a $(tag) -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
204+
git push origin $(tag); \
205+
fi
182206

183207
# check-links:
184208
# @docker run -v ${PWD}:/tmp:ro --rm -i --entrypoint '' ghcr.io/tcort/markdown-link-check:stable /bin/sh -c 'find /tmp/docs.kosli.com/content -name \*.md -print0 | xargs -0 -n1 markdown-link-check -q -c /tmp/link-checker-config.json'

bin/release-interactive.sh

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env bash
2+
# Interactive step after suggest-version-ai: show version and release notes,
3+
# let user edit notes, then confirm before creating tag and pushing.
4+
# Called from Make when running `make release` (no tag).
5+
# Requires: dist/suggested_version and dist/release_notes.md exist.
6+
7+
set -e
8+
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11+
cd "$REPO_ROOT"
12+
13+
SUGGESTED_VERSION_FILE="dist/suggested_version"
14+
RELEASE_NOTES_FILE="dist/release_notes.md"
15+
16+
if [ ! -f "$SUGGESTED_VERSION_FILE" ] || [ ! -f "$RELEASE_NOTES_FILE" ]; then
17+
echo "Missing $SUGGESTED_VERSION_FILE or $RELEASE_NOTES_FILE. Run suggest-version-ai first." >&2
18+
exit 1
19+
fi
20+
21+
VER=$(cat "$SUGGESTED_VERSION_FILE")
22+
if [ -z "$VER" ]; then
23+
echo "Suggested version is empty. Run suggest-version-ai or use: make release tag=vX.Y.Z" >&2
24+
exit 1
25+
fi
26+
27+
echo "Suggested tag: $VER"
28+
echo ""
29+
echo "Release notes ($RELEASE_NOTES_FILE):"
30+
echo "---"
31+
cat "$RELEASE_NOTES_FILE"
32+
echo "---"
33+
echo ""
34+
35+
# Let user edit release notes
36+
read -r -p "Edit release notes? [y/N] " edit_notes
37+
case "$edit_notes" in
38+
y|Y) "${EDITOR:-vi}" "$RELEASE_NOTES_FILE" ;;
39+
*) ;;
40+
esac
41+
42+
echo ""
43+
read -r -p "Create tag $VER and push? [y/N] " confirm
44+
case "$confirm" in
45+
y|Y) ;;
46+
*) echo "Aborted. To release later run: make release tag=$VER"; exit 0 ;;
47+
esac
48+
49+
git remote update
50+
if ! git status -uno | grep -q "Your branch is up to date"; then
51+
echo "ERROR: your branch is NOT up to date with remote" >&2
52+
exit 1
53+
fi
54+
55+
git tag -a "$VER" -F "$RELEASE_NOTES_FILE"
56+
git push origin "$VER"
57+
echo "Pushed tag $VER. Release workflow will run on GitHub."

bin/suggest-version-ai.sh

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env bash
2+
# Suggest next semver and changelog by sending the git diff to Claude.
3+
# Does not rely on commit messages. Changelog is suitable for GoReleaser --release-notes.
4+
#
5+
# Auth (first non-empty wins):
6+
# - ANTHROPIC_API_KEY: call Claude directly.
7+
# - OP_ANTHROPIC_API_KEY_REF: 1Password reference (default below; override if your item path differs).
8+
#
9+
# Optional: CLAUDE_MODEL (default: claude-sonnet-4-6) — e.g. claude-opus-4-6.
10+
#
11+
# Requires: curl, jq; for 1Password: op CLI
12+
# Usage: bin/suggest-version-ai.sh [base_ref] [-o release_notes.md]
13+
# base_ref defaults to the latest git tag.
14+
# -o FILE write changelog markdown to FILE (default: dist/release_notes.md)
15+
#
16+
# Output: bump (major|minor|patch), next_version (e.g. v1.3.0), and changelog file.
17+
18+
set -euo pipefail
19+
20+
BASE_REF=""
21+
RELEASE_NOTES_FILE="dist/release_notes.md"
22+
while [[ $# -gt 0 ]]; do
23+
case "$1" in
24+
-o) RELEASE_NOTES_FILE="$2"; shift 2 ;;
25+
*) BASE_REF="$1"; shift ;;
26+
esac
27+
done
28+
BASE_REF="${BASE_REF:-$(git describe --tags --abbrev=0 2>/dev/null)}"
29+
SUGGESTED_VERSION_FILE="$(dirname "$RELEASE_NOTES_FILE")/suggested_version"
30+
31+
if [ -z "$BASE_REF" ]; then
32+
echo "ERROR: No base ref. Pass a tag or branch, or create a tag first." >&2
33+
exit 1
34+
fi
35+
36+
# Cap diff size to stay within context
37+
MAX_DIFF_CHARS=50000
38+
DIFF="$(git diff "$BASE_REF"..HEAD 2>/dev/null | head -c "$MAX_DIFF_CHARS")"
39+
40+
# Get API key from 1Password if not set (default ref; override with OP_ANTHROPIC_API_KEY_REF)
41+
OP_ANTHROPIC_API_KEY_REF="${OP_ANTHROPIC_API_KEY_REF:-op://Shared/Anthropic API Key/credential}"
42+
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
43+
if command -v op >/dev/null 2>&1; then
44+
ANTHROPIC_API_KEY=$(op read "$OP_ANTHROPIC_API_KEY_REF" 2>/dev/null) || true
45+
fi
46+
fi
47+
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
48+
echo "ERROR: Set ANTHROPIC_API_KEY or OP_ANTHROPIC_API_KEY_REF (1Password)." >&2
49+
exit 1
50+
fi
51+
52+
# Remove stale outputs from a previous run so a failure partway through doesn't mislead the next invocation.
53+
trap 'rm -f "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT
54+
55+
if [ -z "$DIFF" ]; then
56+
echo "No changes since $BASE_REF. Bump: patch (no change)." >&2
57+
CHANGELOG="No code changes since $BASE_REF."
58+
mkdir -p "$(dirname "$RELEASE_NOTES_FILE")"
59+
echo "$CHANGELOG" > "$RELEASE_NOTES_FILE"
60+
echo "patch"
61+
CURRENT="${BASE_REF#v}"
62+
if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
63+
MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}"
64+
NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))"
65+
if [ -n "$(git tag -l "$NEXT")" ]; then
66+
echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2
67+
exit 1
68+
fi
69+
echo "$NEXT" > "$SUGGESTED_VERSION_FILE"
70+
echo "$NEXT"
71+
fi
72+
trap - EXIT
73+
exit 0
74+
fi
75+
76+
PROMPT="You are a release engineer. Given the following git diff for a CLI application (Kosli CLI), do two things.
77+
78+
Scope: Consider ONLY changes to the CLI itself—i.e. code under cmd/ and internal/ that affects user-facing commands, flags, and behavior. IGNORE all other changes when deciding the version and when writing the changelog:
79+
- Ignore: documentation (docs*, *.md), Helm charts (charts/), CI/workflows (.github/), scripts (bin/, scripts/), tests (*_test.go, testdata/), Makefile, config files, and any other non-CLI code.
80+
- If the diff contains only ignored changes, recommend a patch bump and write a single short line for the changelog (e.g. \"No user-facing CLI changes.\").
81+
82+
1) Suggest the semantic version bump (based only on CLI changes):
83+
- major: Breaking changes (removed/renamed commands or flags, changed default behavior).
84+
- minor: New commands, flags, subcommands, or features.
85+
- patch: Bug fixes, refactors, internal or dependency updates; or no user-facing CLI changes.
86+
87+
2) Write a short changelog in markdown for the GitHub release body. Include only user-facing CLI changes. Use bullet points; be concise; no preamble.
88+
- Structure the changelog with section headers (e.g. \"# Breaking changes\", \"# New features\", \"# Bug fixes\" or \"# Improvements\") and list items under each header. Use only headers that have at least one change—omit any section that would be empty.
89+
- Do not write placeholder lines under any header (no \"No other changes\", \"No user-facing CLI changes in this release\", or similar). If there are no CLI changes at all, output a single short line only (no headers).
90+
91+
Reply in this exact format (no other text before or after):
92+
BUMP: major|minor|patch
93+
---CHANGELOG---
94+
<markdown changelog here>"
95+
96+
CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-4-6}"
97+
DIFF_FILE=$(mktemp)
98+
trap 'rm -f "$DIFF_FILE" "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT
99+
printf '%s' "$DIFF" > "$DIFF_FILE"
100+
BODY=$(jq -n \
101+
--arg model "$CLAUDE_MODEL" \
102+
--arg prompt "$PROMPT" \
103+
--rawfile diff "$DIFF_FILE" \
104+
'{model: $model, max_tokens: 1024, messages: [{role: "user", content: ($prompt + "\n\n--- diff ---\n\n" + $diff)}]}')
105+
106+
RESPONSE=$(curl -s -S -X POST "https://api.anthropic.com/v1/messages" \
107+
-H "x-api-key: $ANTHROPIC_API_KEY" \
108+
-H "anthropic-version: 2023-06-01" \
109+
-H "Content-Type: application/json" \
110+
-d "$BODY")
111+
112+
CONTENT=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
113+
if [ -z "$CONTENT" ]; then
114+
echo "ERROR: Anthropic API failed or returned no content. Response:" >&2
115+
echo "$RESPONSE" | jq . >&2
116+
exit 1
117+
fi
118+
119+
BUMP=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]' | grep -oE 'major|minor|patch' | head -1)
120+
case "$BUMP" in
121+
major|minor|patch) ;;
122+
*)
123+
echo "WARN: Could not parse bump (got: $CONTENT). Defaulting to patch." >&2
124+
BUMP=patch
125+
;;
126+
esac
127+
128+
CHANGELOG_MARKER='---CHANGELOG---'
129+
if echo "$CONTENT" | grep -qF -- "$CHANGELOG_MARKER"; then
130+
CHANGELOG=$(echo "$CONTENT" | sed -n "/${CHANGELOG_MARKER}/,\$ p" | tail -n +2)
131+
else
132+
CHANGELOG=$(echo "$CONTENT" | sed -n '2,$ p')
133+
fi
134+
mkdir -p "$(dirname "$RELEASE_NOTES_FILE")"
135+
echo "$CHANGELOG" > "$RELEASE_NOTES_FILE"
136+
137+
# Compute next version from current tag
138+
CURRENT="${BASE_REF#v}"
139+
if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
140+
MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}"
141+
case "$BUMP" in
142+
major) NEXT="v$((MAJOR+1)).0.0" ;;
143+
minor) NEXT="v${MAJOR}.$((MINOR+1)).0" ;;
144+
patch) NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))" ;;
145+
esac
146+
else
147+
NEXT=""
148+
fi
149+
150+
if [ -n "$NEXT" ]; then
151+
if [ -n "$(git tag -l "$NEXT")" ]; then
152+
echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2
153+
exit 1
154+
fi
155+
echo "$NEXT" > "$SUGGESTED_VERSION_FILE"
156+
fi
157+
echo "Suggested bump: $BUMP (from diff since $BASE_REF)" >&2
158+
echo "Next version: $NEXT" >&2
159+
echo "Changelog: $RELEASE_NOTES_FILE" >&2
160+
trap - EXIT
161+
echo "$BUMP"
162+
echo "$NEXT"

release-guide.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,38 @@ Manually triggered from any branch — deploys docs to `staging-docs--kosli-docs
3535

3636
## Release process
3737

38-
A release is triggered by pushing a semver tag:
38+
### Default: interactive release (AI version and changelog)
39+
40+
Run:
41+
42+
```bash
43+
make release
44+
```
45+
46+
**Prerequisites:**
47+
48+
You need:
49+
50+
- **jq**, **curl** installed.
51+
- An Anthropic API key, provided in one of the following ways:
52+
- **Via 1Password**: The **1Password CLI** (`op`), access to the shared vault, and the **1Password desktop app** linked to the CLI so `op` can read secrets. In the 1Password app: **Settings → Developer → Integrate with 1Password CLI**. See [Turn on the 1Password desktop app integration](https://developer.1password.com/docs/cli/get-started#step-2-turn-on-the-1password-desktop-app-integration).
53+
- **Via environment variable**: Set `ANTHROPIC_API_KEY` directly in your environment (no 1Password required).
54+
55+
**What happens:**
56+
57+
1. A script uses Claude to suggest the next semver and draft release notes from the **git diff** (no commit messages). It reads the API key from 1Password via `op` using a default secret reference (vault/item/field); you can override with `OP_ANTHROPIC_API_KEY_REF` if your item lives elsewhere.
58+
2. You see the suggested tag and release notes. You can press Enter to open your editor and edit `dist/release_notes.md`, or Enter to skip.
59+
3. You are prompted: **Create tag vX.Y.Z and push? [y/N]**. On **y**, an annotated tag is created with the release notes as the tag body and pushed. The `release.yml` workflow runs on GitHub; it reads the notes from the tag body and passes them to GoReleaser for the GitHub Release. On **n**, nothing is pushed.
60+
61+
### Fallback: release with an explicit tag
62+
63+
If you don’t want the AI flow (e.g. 1Password/`op` not available or the suggest step failed), run:
3964

4065
```bash
4166
make release tag=v2.x.y
4267
```
4368

44-
This validates the working tree is clean and up to date with the remote, creates an annotated tag, and pushes it. The `release.yml` workflow then runs:
69+
This checks the branch is up to date, creates an annotated tag (using `dist/release_notes.md` as the tag body if that file exists, otherwise the version string), and pushes it. No prompt. The release workflow runs as above; if the tag has no body, GoReleaser uses its default changelog on the GitHub Release.
4570

4671
### 1. Pre-build
4772

0 commit comments

Comments
 (0)