Skip to content
Closed
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
54 changes: 54 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: release
description: Cut a Kosli CLI release - suggest the next semver tag from the changes since the last tag, draft the release notes with the user, then tag and push via `make release`. Use when asked to "release", "cut a release", "tag a new version", or "draft release notes".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something to consider: the last step of this skill pushes a release tag, and the description triggers on loose phrases like "draft release notes" and "tag a new version". If you want it to run only on an explicit /release, add disable-model-invocation: true to the frontmatter. Steps 5 and 6 already ask for confirmation twice, so this is optional. It does mean Claude won't load the skill by itself, though, which goes against the "or ask Claude to cut a release" path in the PR description. Your call.

---

## Overview

A release is an annotated tag on `main`. Its message becomes the top of the GitHub Release:
`release.yml` reads it and hands it to GoReleaser, which appends the "What's Changed" list
of merged PRs. This skill does the judgment work (version bump, notes) and leaves the
mechanics to `make release tag=vX.Y.Z`, which creates the annotated tag from
`dist/release_notes.md` and pushes it. `make release` is the only way the tag gets
created; see the Releasing section of `CLAUDE.md` for why.

## Steps

**1. Check the starting point.** Run `git fetch origin --tags`. Confirm the current
branch is `main`, it matches `origin/main`, and the working tree has no tracked changes.
Find the base with `git describe --tags --abbrev=0`. If any check fails, stop and tell

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git describe --tags --abbrev=0 returns the nearest tag, and that includes pre-release tags. The repo has some already (v2.0.0-rc3, v2.10.3-rc5, …). Once a v2.49.0-rc1 exists, the base becomes the rc. Step 3's "base + bump" then gives an unclear answer (v2.49.0-rc1 + minor = ?), and the notes leave out everything that shipped before the rc. The old script had the same problem, but this is an easy place to fix it:

Suggested change
Find the base with `git describe --tags --abbrev=0`. If any check fails, stop and tell
Find the base with `git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*'` (the last stable release; pre-release tags are skipped). If any check fails, stop and tell

the user what to fix. Done when `main` == `origin/main` and the base tag is known.

**2. Classify every change since the base.** List commits with
`git log --oneline <base>..HEAD`, then read the diffs of the ones that touch `cmd/` or
`internal/` (`git show <sha> -- cmd internal`). Only user-facing CLI changes count:
commands, flags, output, defaults, error behaviour, and fixes a user would notice.
Tests, `testdata/`, docs, `charts/`, `.github/`, `bin/`, scripts, the Makefile and
dependency bumps without behaviour change do not. Done when every commit is marked
user-facing or not, with a one-line reason.

**3. Pick the bump.** Base it only on the user-facing changes:

- **major**: a removed or renamed command or flag, or a changed default.
- **minor**: a new command, subcommand, flag or feature.
- **patch**: bug fixes, refactors, dependency updates, or no user-facing changes.

Compute the next tag from the base (`v2.48.2` + minor -> `v2.49.0`) and confirm it does
not exist yet with `git tag -l <next>`. A pre-release tag (`v2.49.0-rc1`) only when the
user asks for one.

**4. Draft `dist/release_notes.md`.** Write user-facing CLI changes only, as concise
bullets under `#` headings, using only the headings that have entries, in this order:
`# Breaking changes`, `# New features`, `# Improvements`, `# Bug fixes`. Each bullet
names the command or flag and what changed for the user. When there are no user-facing
changes, the file is a single line with no heading: `No user-facing CLI changes.`
Create `dist/` if needed and overwrite any file already there.

**5. Review with the user.** Show the suggested tag, the reasoning for the bump (the
user-facing changes that drove it), and the notes. Apply every edit they ask for to
`dist/release_notes.md` and show the result again. Done when the user explicitly
approves both the tag and the notes.

**6. Tag and push.** Pushing the tag starts the release pipeline, so ask once more:
"Create and push `<tag>`?". On a yes, run `make release tag=<tag>`. Then report the run
with `gh run list --workflow release.yml --limit 1`.
Comment on lines +52 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small issues with the last step:

  1. Leftover notes file. After a successful push, dist/release_notes.md stays on disk. The next hand-run make release tag= quietly uses it as the tag body. The PR description and the guide both mention this, but the skill can prevent it by deleting the file. The old script had a trap that did a similar cleanup.
  2. Wrong run reported. gh run list --workflow release.yml --limit 1 runs straight after the push. GitHub often hasn't registered the new run yet, so this can show the previous release. A tag push's run has headBranch set to the tag name, so filtering on the tag avoids that.
Suggested change
**6. Tag and push.** Pushing the tag starts the release pipeline, so ask once more:
"Create and push `<tag>`?". On a yes, run `make release tag=<tag>`. Then report the run
with `gh run list --workflow release.yml --limit 1`.
**6. Tag and push.** Pushing the tag starts the release pipeline, so ask once more:
"Create and push `<tag>`?". On a yes, run `make release tag=<tag>`, then delete
`dist/release_notes.md` so a later hand-run `make release` can't reuse it. Report the run
with `gh run list --workflow release.yml --branch <tag> --limit 1` (retry for a few seconds if it isn't listed yet).

9 changes: 1 addition & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,7 @@ All command tests in `cmd/kosli/` use the **testify suite** pattern:

**NEVER use `gh release create` to cut a release.** It creates a lightweight tag and a premature GitHub Release, both of which break the release pipeline (never-alone-trail fails, GoReleaser conflicts with the pre-existing release).

The release process is documented in [release-guide.md](/release-guide.md). The correct way to release:

```bash
make release # Interactive: AI suggests version + changelog, you confirm
make release tag=v2.x.y # Fallback: explicit tag, no AI
```

Both paths create an **annotated tag** and push it. The `release.yml` workflow then runs GoReleaser, which creates the GitHub Release at the correct point in the pipeline. Do not create tags or releases via any other method.
The release process is documented in [release-guide.md](/release-guide.md). To cut a release, use the `release` skill (`.claude/skills/release/`): it drafts the version and notes with the user, then runs `make release tag=v2.x.y`. That make target is the only path that creates the tag: it makes an **annotated tag** carrying `dist/release_notes.md` and pushes it. The `release.yml` workflow then runs GoReleaser, which creates the GitHub Release at the correct point in the pipeline. Do not create tags or releases via any other method.

## Working Style: TDD

Expand Down
37 changes: 10 additions & 27 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help build clean clean-cache deps fmt lint vet docker ldflags ensure_golangci-lint check_dirty add_test_tag build_release ensure_network ensure_gotestsum test_setup test_integration test_integration_full test_integration_single licenses upgrade-deps helm-lint helm-docs docs release suggest-version-ai
.PHONY: help build clean clean-cache deps fmt lint vet docker ldflags ensure_golangci-lint check_dirty add_test_tag build_release ensure_network ensure_gotestsum test_setup test_integration test_integration_full test_integration_single licenses upgrade-deps helm-lint helm-docs docs release
.DEFAULT: help
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
Expand Down Expand Up @@ -216,32 +216,15 @@ docs: build ## Generate CLI reference docs (use CMD="attest snyk" for one comman
@./kosli docs --dir client_reference $(CMD)
@echo "Docs written to client_reference/"

# Suggest next semver and changelog using Claude.
# Writes changelog to dist/release_notes.md, used as the annotated tag's body.
# Requires: jq, curl, op (1Password CLI). API key from 1Password via op.
# Usage: make suggest-version-ai [BASE_REF=v1.2.3]
suggest-version-ai: ## Suggest next version using AI
@command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1)
@command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1)
@bin/suggest-version-ai.sh $(BASE_REF) -o dist/release_notes.md

# Release: without tag → suggest version + changelog, then interactive edit & confirm, then tag and push.
# With tag → escape hatch: create annotated tag (body = dist/release_notes.md if present), push. No AI, no prompt.
# Creates an annotated tag (body = dist/release_notes.md if present) and pushes it.
# Release notes are carried in the tag message so GitHub Actions can pass them to GoReleaser.
release: ## Cut a new release and push next tag
# --cleanup=verbatim stops git stripping the notes' markdown '#' headings as comments.
# To draft the version and notes with Claude, use the release skill (/release in Claude Code).
release: ## Cut a new release and push the tag: make release tag=vX.Y.Z
@current=$$(git branch --show-current 2>/dev/null || git rev-parse --abbrev-ref HEAD); \
if [ "$$current" != "main" ]; then echo "ERROR: release must be run from main branch (current: $$current)"; exit 1; fi; \
if [ -z "$(tag)" ]; then \
command -v jq >/dev/null 2>&1 || (echo "Install jq (e.g. brew install jq)" && exit 1); \
command -v curl >/dev/null 2>&1 || (echo "Install curl (e.g. brew install curl)" && exit 1); \
bin/suggest-version-ai.sh -o dist/release_notes.md; \
if [ ! -f dist/suggested_version ]; then \
echo "Suggestion failed or no previous tag. Use: make release tag=vX.Y.Z"; exit 1; \
fi; \
bin/release-interactive.sh; \
else \
git remote update; \
git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && exit 1); \
([ -f dist/release_notes.md ] && git tag -a $(tag) --cleanup=verbatim -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
git push origin $(tag); \
fi
if [ -z "$(tag)" ]; then echo "Usage: make release tag=vX.Y.Z (or run /release in Claude Code to draft version and notes)"; exit 1; fi; \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this check comes after the main branch check. So running a bare make release from a feature branch prints "must be run from main" rather than the usage line that points to /release. Putting the tag check first would show the usage hint wherever it's run. It's harmless either way.

git remote update; \
git status -uno | grep --silent "Your branch is up to date" || (echo "ERROR: your branch is NOT up to date with remote" && exit 1); \
([ -f dist/release_notes.md ] && git tag -a $(tag) --cleanup=verbatim -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
git push origin $(tag)
59 changes: 0 additions & 59 deletions bin/release-interactive.sh

This file was deleted.

169 changes: 0 additions & 169 deletions bin/suggest-version-ai.sh

This file was deleted.

Loading
Loading