Skip to content
Merged
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
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,23 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}

# 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.
- name: Get release notes from tag
id: get-tag-notes
run: |
TAG="${GITHUB_REF#refs/tags/}"
BODY=$(git tag -l --format='%(contents:body)' "$TAG")
if [ -n "$BODY" ]; then
mkdir -p dist
printf '%s' "$BODY" > dist/release_notes.md
echo "args=--release-notes=dist/release_notes.md" >> $GITHUB_OUTPUT
fi

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --clean
args: release --clean ${{ steps.get-tag-notes.outputs.args }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FURY_TOKEN: ${{ secrets.FURY_TOKEN }}
Expand Down
32 changes: 28 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,35 @@ helm-docs: helm-lint
@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
@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

# Suggest next semver and changelog using Claude.
# Writes changelog to dist/release_notes.md for use with goreleaser --release-notes.
# 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:
@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.
# Release notes are carried in the tag message so GitHub Actions can pass them to GoReleaser.
release:
@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" && return 1)
git tag -a $(tag) -m"$(tag)"
git push origin $(tag)
@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) -F dist/release_notes.md) || git tag -a $(tag) -m"$(tag)"; \
git push origin $(tag); \
fi

# check-links:
# @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'
Expand Down
57 changes: 57 additions & 0 deletions bin/release-interactive.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/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

git tag -a "$VER" -F "$RELEASE_NOTES_FILE"
git push origin "$VER"
echo "Pushed tag $VER. Release workflow will run on GitHub."
162 changes: 162 additions & 0 deletions bin/suggest-version-ai.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Suggest next semver and changelog by sending the git diff to Claude.
# Does not rely on commit messages. Changelog is suitable for GoReleaser --release-notes.
#
# 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-4-6) — e.g. claude-opus-4-6.
#
# 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
MAX_DIFF_CHARS=50000
DIFF="$(git diff "$BASE_REF"..HEAD 2>/dev/null | head -c "$MAX_DIFF_CHARS")"

# 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---
<markdown changelog here>"

CLAUDE_MODEL="${CLAUDE_MODEL:-claude-sonnet-4-6}"
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: 1024, 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")

CONTENT=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
if [ -z "$CONTENT" ]; then
echo "ERROR: Anthropic API failed or returned no content. Response:" >&2
echo "$RESPONSE" | jq . >&2
exit 1
fi

BUMP=$(echo "$CONTENT" | tr '[:upper:]' '[:lower:]' | grep -oE 'major|minor|patch' | head -1)
case "$BUMP" in
major|minor|patch) ;;
*)
echo "WARN: Could not parse bump (got: $CONTENT). Defaulting to patch." >&2
BUMP=patch
;;
esac

CHANGELOG_MARKER='---CHANGELOG---'
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
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"
29 changes: 27 additions & 2 deletions release-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,38 @@ Manually triggered from any branch — deploys docs to `staging-docs--kosli-docs

## Release process

A release is triggered by pushing a semver tag:
### Default: interactive release (AI version and changelog)

Run:

```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. 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 for the GitHub Release. On **n**, nothing is pushed.

### Fallback: 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:

```bash
make release tag=v2.x.y
```

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:
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.

### 1. Pre-build

Expand Down
Loading