Skip to content

Changelog

Changelog #16

Workflow file for this run

name: Changelog
# claude-code-action does not support the `release` event, so the release
# trigger only re-dispatches this workflow as a workflow_dispatch run.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: Release tag to write the changelog for
required: true
jobs:
dispatch:
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Re-dispatch as workflow_dispatch
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: gh workflow run changelog.yml --repo "$GITHUB_REPOSITORY" --ref main -f "tag=$TAG"
changelog:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Collect changes since previous release
id: commits
env:
TAG: ${{ inputs.tag }}
run: |
prev=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)
if [ -n "$prev" ]; then
range="${prev}..${TAG}"
diffbase="$prev"
else
# First release: diff against the empty tree so every file counts as added.
range="$TAG"
diffbase=$(git hash-object -t tree /dev/null)
fi
echo "Previous tag: ${prev:-none (first release)}"
echo "Range: $range"
# Exclude generated, vendored, and lock files so the diff reflects
# real source changes, not machine output. These are the source of
# truth for the notes — commit subjects are only a secondary hint.
exclude=(
':(exclude)CHANGELOG.md'
':(exclude)*.lock'
':(exclude)package-lock.json'
':(exclude)composer.lock'
':(exclude)resources/js/routes/**'
':(exclude)resources/js/actions/**'
)
{
echo 'log<<COMMITS_EOF'
git log --no-merges --pretty=format:'%h %s' "$range"
echo
echo 'COMMITS_EOF'
} >> "$GITHUB_OUTPUT"
{
echo 'diffstat<<DIFFSTAT_EOF'
git diff --stat "$diffbase" "$TAG" -- . "${exclude[@]}"
echo 'DIFFSTAT_EOF'
} >> "$GITHUB_OUTPUT"
# Cap the diff so a large release can't blow past the env-var size
# limit that feeds the prompt. Signal truncation if we hit the cap.
full=$(git diff "$diffbase" "$TAG" -- . "${exclude[@]}")
capped=$(printf '%s\n' "$full" | head -c 90000)
{
echo 'diff<<DIFF_EOF'
printf '%s\n' "$capped"
if [ "${#full}" -gt "${#capped}" ]; then
echo
echo '[diff truncated — see the file list above for the full scope of changes]'
fi
echo 'DIFF_EOF'
} >> "$GITHUB_OUTPUT"
- name: Write changelog with Claude
id: changelog
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# This workflow is intentionally re-dispatched by the release job, so
# the workflow_dispatch run is initiated by github-actions[bot].
allowed_bots: github-actions
prompt: |
Write a technical changelog for release ${{ inputs.tag }} by reading the actual code diff below and describing what changed.
Rules:
- The DIFF is the source of truth. Read the added/removed lines and describe what the code now does that it didn't before. Do not rely on the commit subjects to tell you what changed — they are only a rough hint for naming and grouping, and are often vague or incomplete. If the diff adds a capability the commit subject never mentions, the changelog must still cover it.
- Do not invent or infer anything the diff does not show. Never describe a removal as an addition: if lines/files were deleted, that capability is gone.
- Group entries under exactly these headings, in this order: `### Features`, `### Fixes`, `### Chores`. Omit any heading that has no entries.
- Features are user-facing additions or changes. Fixes are bug fixes. Everything else (refactors, CI, dependencies, tooling, docs, removals of internal scaffolding) is a Chore.
- One bullet per meaningful change. Split distinct capabilities into separate bullets even when they came from a single commit. Skip pure merge/version-bump noise.
- Write each bullet as a plain past-tense sentence. No commit hashes, no PR numbers, no marketing language.
Return ONLY the markdown for these sections as the `body` field of the structured output — no release heading, no preamble. If the diff shows no meaningful changes, set `body` to "_No changes._".
Commit subjects (rough hint only — one per line, `<hash> <subject>`):
${{ steps.commits.outputs.log }}
Files changed:
${{ steps.commits.outputs.diffstat }}
Code diff (the source of truth):
```diff
${{ steps.commits.outputs.diff }}
```
claude_args: |
--json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"]}'
--allowedTools ""
- name: Update CHANGELOG.md
env:
STRUCTURED: ${{ steps.changelog.outputs.structured_output }}
TAG: ${{ inputs.tag }}
run: |
body=$(echo "$STRUCTURED" | jq -r '.body')
if [ -z "$body" ] || [ "$body" = "null" ]; then
echo "Claude did not produce a changelog body." >&2
exit 1
fi
if [ -f CHANGELOG.md ] && grep -q "^## ${TAG} " CHANGELOG.md; then
echo "CHANGELOG.md already has a section for ${TAG}, skipping."
exit 0
fi
tmp=$(mktemp)
{
echo "# Changelog"
echo
echo "## ${TAG} - $(date -u +%Y-%m-%d)"
echo
echo "$body"
echo
if [ -f CHANGELOG.md ]; then
sed '1{/^# Changelog$/d;}' CHANGELOG.md | sed '/./,$!d'
fi
} > "$tmp"
mv "$tmp" CHANGELOG.md
- name: Commit CHANGELOG.md
env:
TAG: ${{ inputs.tag }}
GITHUB_TOKEN: ${{ github.token }}
run: |
git add CHANGELOG.md
if git diff --cached --quiet -- CHANGELOG.md; then
echo "No changelog changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "Update CHANGELOG.md for ${TAG}"
# claude-code-action replaces the checkout credentials, so push with
# the workflow token explicitly.
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main
- name: Write release notes with Claude
id: release_notes
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: github-actions
prompt: |
Write user-facing release notes for release ${{ inputs.tag }} by reading the actual code diff below and describing what changed for the user.
Audience: the people who use the app, not developers. Write for them, and make them excited to try what's new.
Tone:
- Fun, playful, and full of personality — like a small team that loves what they're building and can't wait for people to try it. Have a sense of humour. Make the reader smile.
- Warm and energetic. Lead with the delight: what's more fun, faster, or easier now. A well-placed emoji is welcome (roughly one per bullet, tops) when it adds warmth rather than noise.
- Keep it real. Playful never means fake: no empty hype words that say nothing ("game-changing", "revolutionary", "supercharged"), no wall of exclamation marks, no forced enthusiasm about something boring.
- Every ounce of fun must be backed by something actually in the commits. The personality lives in HOW you say it — never in inventing a capability that isn't there.
Rules:
- The DIFF is the source of truth. Read the added/removed lines and work out what a user can now do that they couldn't before. Do NOT rely on the commit subjects — they are only a rough hint and are often vague (a single "Modify feedback" commit might add BOTH editing and deleting; the diff will show both, so cover both). Anything the diff clearly adds must appear, even if no commit subject mentions it.
- Read routes, controllers, buttons, form fields, and UI text in the diff to discover user-facing capabilities. A new route + a new button labelled "Delete" means users can now delete — say so.
- Do not invent or infer anything the diff does not show. Never describe a removal as an addition: if lines/files were deleted, that capability is gone.
- Plain English. No jargon, no code identifiers, no framework or library names, no commit hashes, no PR numbers.
- Frame every entry around what changed for the user — what they can now do, what now works better, what looks or behaves differently.
- Skip anything purely internal that a user would never notice: refactors, CI, dependency bumps, tooling, tests, migrations, docs, and internal scaffolding. If a change has no user-visible effect, leave it out entirely.
- Be granular. Give each distinct user-facing capability its own bullet — if the diff adds editing AND deleting, that is TWO bullets, not one. Only fold together changes that are genuinely the same thing.
- Structure each bullet as **A short bold headline** — then a sentence or two of plain-English detail: exactly what was added or changed, and where the user finds or uses it. Enough specificity that a reader knows precisely what's new without seeing the app.
- Group related bullets under short `###` headings when the release is large enough to warrant it (e.g. `### New`, `### Improved`, `### Fixed`). For a small release, a single flat bullet list is fine — don't force headings.
Return ONLY the markdown as the `body` field of the structured output — no top-level release heading, no preamble. If nothing in the diff is user-facing, set `body` to "_No user-facing changes in this release._".
Commit subjects (rough hint only — one per line, `<hash> <subject>`):
${{ steps.commits.outputs.log }}
Files changed:
${{ steps.commits.outputs.diffstat }}
Code diff (the source of truth):
```diff
${{ steps.commits.outputs.diff }}
```
claude_args: |
--json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"]}'
--allowedTools ""
- name: Update release description
env:
STRUCTURED: ${{ steps.release_notes.outputs.structured_output }}
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
body=$(echo "$STRUCTURED" | jq -r '.body')
if [ -z "$body" ] || [ "$body" = "null" ]; then
echo "Claude did not produce release notes." >&2
exit 1
fi
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes "$body"