diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 000000000..a91c7713b --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -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". +--- + +## 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 +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 ..HEAD`, then read the diffs of the ones that touch `cmd/` or +`internal/` (`git show -- 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 `. 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 ``?". On a yes, run `make release tag=`. Then report the run +with `gh run list --workflow release.yml --limit 1`. diff --git a/CLAUDE.md b/CLAUDE.md index b678f831d..107b88bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile index 53a1951d8..b13209489 100644 --- a/Makefile +++ b/Makefile @@ -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: @@ -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; \ + 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) diff --git a/bin/release-interactive.sh b/bin/release-interactive.sh deleted file mode 100755 index 348a04e67..000000000 --- a/bin/release-interactive.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# Interactive step after suggest-version-ai: show version and release notes, -# let user edit notes, then confirm before creating tag and pushing. -# Called from Make when running `make release` (no tag). -# Requires: dist/suggested_version and dist/release_notes.md exist. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$REPO_ROOT" - -SUGGESTED_VERSION_FILE="dist/suggested_version" -RELEASE_NOTES_FILE="dist/release_notes.md" - -if [ ! -f "$SUGGESTED_VERSION_FILE" ] || [ ! -f "$RELEASE_NOTES_FILE" ]; then - echo "Missing $SUGGESTED_VERSION_FILE or $RELEASE_NOTES_FILE. Run suggest-version-ai first." >&2 - exit 1 -fi - -VER=$(cat "$SUGGESTED_VERSION_FILE") -if [ -z "$VER" ]; then - echo "Suggested version is empty. Run suggest-version-ai or use: make release tag=vX.Y.Z" >&2 - exit 1 -fi - -echo "Suggested tag: $VER" -echo "" -echo "Release notes ($RELEASE_NOTES_FILE):" -echo "---" -cat "$RELEASE_NOTES_FILE" -echo "---" -echo "" - -# Let user edit release notes -read -r -p "Edit release notes? [y/N] " edit_notes -case "$edit_notes" in - y|Y) "${EDITOR:-vi}" "$RELEASE_NOTES_FILE" ;; - *) ;; -esac - -echo "" -read -r -p "Create tag $VER and push? [y/N] " confirm -case "$confirm" in - y|Y) ;; - *) echo "Aborted. To release later run: make release tag=$VER"; exit 0 ;; -esac - -git remote update -if ! git status -uno | grep -q "Your branch is up to date"; then - echo "ERROR: your branch is NOT up to date with remote" >&2 - exit 1 -fi - -# --cleanup=verbatim: git's default cleanup strips lines starting with '#' as -# commentary, which would eat the markdown headings in the release notes. -git tag -a "$VER" --cleanup=verbatim -F "$RELEASE_NOTES_FILE" -git push origin "$VER" -echo "Pushed tag $VER. Release workflow will run on GitHub." diff --git a/bin/suggest-version-ai.sh b/bin/suggest-version-ai.sh deleted file mode 100755 index 8ec327e65..000000000 --- a/bin/suggest-version-ai.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -# Suggest next semver and changelog by sending the git diff to Claude. -# Does not rely on commit messages. Changelog will be attached as a header to release Changelog. -# -# Auth (first non-empty wins): -# - ANTHROPIC_API_KEY: call Claude directly. -# - OP_ANTHROPIC_API_KEY_REF: 1Password reference (default below; override if your item path differs). -# -# Optional: CLAUDE_MODEL (default: claude-sonnet-5) -# -# Requires: curl, jq; for 1Password: op CLI -# Usage: bin/suggest-version-ai.sh [base_ref] [-o release_notes.md] -# base_ref defaults to the latest git tag. -# -o FILE write changelog markdown to FILE (default: dist/release_notes.md) -# -# Output: bump (major|minor|patch), next_version (e.g. v1.3.0), and changelog file. - -set -euo pipefail - -BASE_REF="" -RELEASE_NOTES_FILE="dist/release_notes.md" -while [[ $# -gt 0 ]]; do - case "$1" in - -o) RELEASE_NOTES_FILE="$2"; shift 2 ;; - *) BASE_REF="$1"; shift ;; - esac -done -BASE_REF="${BASE_REF:-$(git describe --tags --abbrev=0 2>/dev/null)}" -SUGGESTED_VERSION_FILE="$(dirname "$RELEASE_NOTES_FILE")/suggested_version" - -if [ -z "$BASE_REF" ]; then - echo "ERROR: No base ref. Pass a tag or branch, or create a tag first." >&2 - exit 1 -fi - -# Cap diff size to stay within context (avoid SIGPIPE when diff is large: head exits first) -MAX_DIFF_CHARS=50000 -set +o pipefail -DIFF="$(git diff "$BASE_REF"..HEAD 2>/dev/null | head -c "$MAX_DIFF_CHARS")" -set -o pipefail - -# Get API key from 1Password if not set (default ref; override with OP_ANTHROPIC_API_KEY_REF) -OP_ANTHROPIC_API_KEY_REF="${OP_ANTHROPIC_API_KEY_REF:-op://Shared/Anthropic API Key/credential}" -if [ -z "${ANTHROPIC_API_KEY:-}" ]; then - if command -v op >/dev/null 2>&1; then - ANTHROPIC_API_KEY=$(op read "$OP_ANTHROPIC_API_KEY_REF" 2>/dev/null) || true - fi -fi -if [ -z "${ANTHROPIC_API_KEY:-}" ]; then - echo "ERROR: Set ANTHROPIC_API_KEY or OP_ANTHROPIC_API_KEY_REF (1Password)." >&2 - exit 1 -fi - -# Remove stale outputs from a previous run so a failure partway through doesn't mislead the next invocation. -trap 'rm -f "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT - -if [ -z "$DIFF" ]; then - echo "No changes since $BASE_REF. Bump: patch (no change)." >&2 - CHANGELOG="No code changes since $BASE_REF." - mkdir -p "$(dirname "$RELEASE_NOTES_FILE")" - echo "$CHANGELOG" > "$RELEASE_NOTES_FILE" - echo "patch" - CURRENT="${BASE_REF#v}" - if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then - MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}" - NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))" - if [ -n "$(git tag -l "$NEXT")" ]; then - echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2 - exit 1 - fi - echo "$NEXT" > "$SUGGESTED_VERSION_FILE" - echo "$NEXT" - fi - trap - EXIT - exit 0 -fi - -PROMPT="You are a release engineer. Given the following git diff for a CLI application (Kosli CLI), do two things. - -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: -- 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. -- 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.\"). - -1) Suggest the semantic version bump (based only on CLI changes): - - major: Breaking changes (removed/renamed commands or flags, changed default behavior). - - minor: New commands, flags, subcommands, or features. - - patch: Bug fixes, refactors, internal or dependency updates; or no user-facing CLI changes. - -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. - - 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. - - 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). - -Reply in this exact format (no other text before or after): -BUMP: major|minor|patch ----CHANGELOG--- -" - -CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-5}" -DIFF_FILE=$(mktemp) -trap 'rm -f "$DIFF_FILE" "$RELEASE_NOTES_FILE" "$SUGGESTED_VERSION_FILE"' EXIT -printf '%s' "$DIFF" > "$DIFF_FILE" -BODY=$(jq -n \ - --arg model "$CLAUDE_MODEL" \ - --arg prompt "$PROMPT" \ - --rawfile diff "$DIFF_FILE" \ - '{model: $model, max_tokens: 4096, messages: [{role: "user", content: ($prompt + "\n\n--- diff ---\n\n" + $diff)}]}') - -RESPONSE=$(curl -s -S -X POST "https://api.anthropic.com/v1/messages" \ - -H "x-api-key: $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - -H "Content-Type: application/json" \ - -d "$BODY") - -# The response may start with a thinking block, so join all text blocks instead of reading content[0]. -CONTENT=$(echo "$RESPONSE" | jq -r '[.content[]? | select(.type == "text") | .text] | join("\n")') -if [ -z "$CONTENT" ]; then - echo "ERROR: Anthropic API failed or returned no content. Response:" >&2 - echo "$RESPONSE" | jq . >&2 - exit 1 -fi - -set +o pipefail -BUMP=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]' | grep -oE 'major|minor|patch' | head -1) -set -o pipefail -case "$BUMP" in - major|minor|patch) ;; - *) - echo "WARN: Could not parse bump (got: $CONTENT). Defaulting to patch." >&2 - BUMP=patch - ;; -esac - -CHANGELOG_MARKER='---CHANGELOG---' -set +o pipefail -if echo "$CONTENT" | grep -qF -- "$CHANGELOG_MARKER"; then - CHANGELOG=$(echo "$CONTENT" | sed -n "/${CHANGELOG_MARKER}/,\$ p" | tail -n +2) -else - CHANGELOG=$(echo "$CONTENT" | sed -n '2,$ p') -fi -set -o pipefail -mkdir -p "$(dirname "$RELEASE_NOTES_FILE")" -echo "$CHANGELOG" > "$RELEASE_NOTES_FILE" - -# Compute next version from current tag -CURRENT="${BASE_REF#v}" -if [[ "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then - MAJOR="${CURRENT%%.*}"; REST="${CURRENT#*.}"; MINOR="${REST%%.*}"; PATCH="${REST#*.}"; PATCH="${PATCH%%[-+]*}" - case "$BUMP" in - major) NEXT="v$((MAJOR+1)).0.0" ;; - minor) NEXT="v${MAJOR}.$((MINOR+1)).0" ;; - patch) NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))" ;; - esac -else - NEXT="" -fi - -if [ -n "$NEXT" ]; then - if [ -n "$(git tag -l "$NEXT")" ]; then - echo "ERROR: tag $NEXT already exists. Push it or use: make release tag=$NEXT" >&2 - exit 1 - fi - echo "$NEXT" > "$SUGGESTED_VERSION_FILE" -fi -echo "Suggested bump: $BUMP (from diff since $BASE_REF)" >&2 -echo "Next version: $NEXT" >&2 -echo "Changelog: $RELEASE_NOTES_FILE" >&2 -trap - EXIT -echo "$BUMP" -echo "$NEXT" diff --git a/release-guide.md b/release-guide.md index 0c168d0af..d14b5b39d 100644 --- a/release-guide.md +++ b/release-guide.md @@ -24,47 +24,35 @@ Triggers when files under `charts/` change on `main`: ## Release process -### Default: interactive release (AI version and changelog) +### Default: release with the Claude Code `release` skill -Run: +In Claude Code, on an up-to-date `main`, run `/release` (or ask Claude to cut a release). The skill at `.claude/skills/release/SKILL.md`: -```bash -make release -``` - -**Prerequisites:** - - You need: - -- **jq**, **curl** installed. -- An Anthropic API key, provided in one of the following ways: - - **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). - - **Via environment variable**: Set `ANTHROPIC_API_KEY` directly in your environment (no 1Password required). - -**What happens:** +1. Reads the commits and diffs since the last tag and picks out the user-facing CLI changes. +2. Suggests the next semver tag, with the changes that justify the bump. +3. Drafts `dist/release_notes.md` and revises it with you until you approve it. +4. After a final confirmation, runs `make release tag=vX.Y.Z`, which creates an annotated tag with the release notes as the tag body and pushes it. The `release.yml` workflow reads the notes from the tag body and passes them to GoReleaser, which puts them at the top of the GitHub Release. -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. -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. -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, which puts them at the top of the GitHub Release. On **n**, nothing is pushed. +It runs under your own Claude login; no Anthropic API key is involved. ### Release body layout The release body has two parts: -1. The notes carried in the tag body — AI-drafted, edited by you, describing user-facing CLI changes. +1. The notes carried in the tag body — drafted with the `release` skill or written by hand, describing user-facing CLI changes. 2. GitHub's **What's Changed** list of merged PRs and the **Full Changelog** compare link, from GoReleaser's `github-native` changelog. Dependency bumps are filtered out by `.github/release.yml`. A tag with no notes gets part 2 only. -### Fallback: release with an explicit tag +### Without Claude Code: release with an explicit tag -If you don’t want the AI flow (e.g. 1Password/`op` not available or the suggest step failed), run: +Without Claude Code, run: ```bash make release tag=v2.x.y ``` -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 notes, the release body is the auto-generated **What's Changed** list alone. +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, so check that any `dist/release_notes.md` left over from an earlier release is gone or rewritten first. The release workflow runs as above; if the tag has no notes, the release body is the auto-generated **What's Changed** list alone. ### 1. Pre-build