release-digest
GitHub生成自上次发布以来合并到 main 分支的变更摘要,按作者分组并分类为功能、修复或任务,用于发布前 QA 准备及 Slack 通报。
Trigger Scenarios
Install
npx skills add Kiln-AI/Kiln --skill release-digest -g -y
SKILL.md
Frontmatter
{
"name": "release-digest",
"description": "Post a \"what's changed since the last release\" recap to the release Slack channel for final QA. Groups merged PRs by author and classifies each as feature \/ bug fix \/ task. Use when the user wants a release recap, a changelog since the last tag, or to prep QA before cutting a release from main."
}
Release Digest
Build a human-readable recap of everything merged to main that is not yet in the
latest release tag, and post it to the team's release Slack channel for final QA.
The recap is grouped by author and each change is classified as feature, bug fix, or task. This is on-demand: run it when someone asks for a release recap or wants to QA before cutting a release.
Linear ticket tracking ("which tickets are targeted for this release") is out of scope for now — punted to a later iteration. This skill only covers what's changed.
Phase 1 — Gather the change set
Run the gather script from the repo root. It fetches tags + origin/main, finds the
latest tag, and lists every merged PR in the range <last_tag>..origin/main:
python3 .agents/skills/release-digest/scripts/gather_changes.py > /tmp/release_digest.json
Key design point: it uses the git commit range as the source of truth, not merge dates. A PR merged the same day a tag was cut can already be in that release, so a date filter would over-report. Trust the range.
The JSON contains:
last_tag— the most recent release (e.g.v1.0.3)head_ref—origin/main(ormainif no remote ref)pr_count,commit_countprs[]—{number, title, author_name, author_login, url, labels, additions, deletions, body}direct_commits[]— teammate commits pushed straight to main, no PR —{sha, title, author_login, author_name}excluded_prs[]— PRs/commits from non-teammates, dropped from the recap. PR entries have anumber; direct-commit entries havenumber: nulland rely ontitle.unresolved_prs[]— PRs whosegh pr viewfailed ({number, error}). If non-empty, the change set is partial — tell the user which PRs couldn't load before posting.
Only the five teammates in the script's TEAM map (Sam, Leonard, Daniel, Mike,
Steve) are included in the recap. PRs from anyone else are filtered into
excluded_prs rather than itemized. To add a teammate, edit TEAM in the script.
If pr_count is 0 AND direct_commits is empty: tell the user main is even with
<last_tag> — nothing to QA or release yet. Do not post to Slack. Stop here. (Direct
commits alone, with no PRs, still count as unreleased changes worth a digest.)
Phase 2 — Classify and group
Read /tmp/release_digest.json. Treat every item in prs[] and direct_commits[]
as a change to recap (direct commits are real work that shipped — just with no PR
number). Classify each into one of three buckets:
- Feature — a substantive new product capability: new UI, a new endpoint, a new tool, new eval/RAG/fine-tune functionality, or a meaningful enhancement to how the product works. Reserve this for things a user would notice as "Kiln can now do X."
- Bug fix — corrects broken or incorrect behavior: regressions, crashes, hangs, wrong output, security fixes.
- Task — everything that is neither a substantive new capability nor a fix to broken behavior: refactors, docs/README, tests, CI/build, dependency bumps, version bumps, chores, internal flags, code removal/cleanup — and routine model-catalog work (see below). Matches Linear's "Task" issue type: maintenance/internal work.
Adding models is a Task, not a Feature. Kiln ships new model and provider entries
constantly — "Add Add/Enable. Only promote to Feature if the PR adds a genuinely
new capability around models (e.g. a new thinking-level control, a new extraction
modality, a new provider integration type) rather than just registering a model_id.
Decision order (use the first signal that's decisive):
- PR labels —
bug/fix→ Bug fix;feature/enhancement→ Feature;chore/docs/refactor/ci→ Task. (Kiln PRs are usually unlabeled, so this rarely fires — fall through to the title.) - Model-catalog check — if the change just registers/edits model or provider
entries (not new behavior), it's a Task, regardless of the
Add/Enableverb. - Conventional-commit / verb prefix in the title —
fix:/Fix→ Bug fix;feat:/Add/Enable(a new capability) → Feature;chore:/refactor:/ci:/docs:/Bump/Remove/Deprecate/Version bump→ Task. - What the change does (read the title, and
bodyif still unclear) — ask "does this give the user a genuinely new capability (Feature), fix something that was broken (Bug fix), or is it routine/maintenance (Task)?"
"Task" is the residual bucket, not a guess: a change lands there only because it
is positively not a new capability and not a fix — e.g. Deprecate Models,
min age for dependencies, Version bump, new draft readme, fix typos. When a
change is genuinely ambiguous between Feature and Bug fix, prefer the one the title
leans toward; only fall to Task when it's truly neither. This is a judgment call made
per item — surface it to the user before posting so they can re-bucket anything.
Then group by author (author_name). Within each author, list changes by PR
number, newest first; put any direct commits (no PR number) last. The feature / fix /
task split is not shown per line — it only feeds the tally at the bottom.
Phase 3 — Compose the Slack message
First, ask the user for the new release name — the version being cut (e.g.
v1.0.4). This is the release these changes are going into; it isn't tagged yet, so
it can't be derived from git. Use their answer as <new_release> in the title.
Use this shape (Slack mrkdwn — *bold*, not **bold**):
:rocket: *v1.0.4 Release Status* :rocket:
*12 changes since v1.0.3*
_<one-to-two sentence plain-English recap of the headline changes>_
*Daniel Chiang*
• Add deprecated flag to embedding models (#1466)
• Add deprecated flag to reranker models (#1465)
*Sam Fierro*
• Dedup frozen prompts and bake type into their name (#1501)
*Steve Cosman*
• Add Jinja2 input transform on RunConfigs (#1433)
• Version bump in lock and desktop app version check
:bar_chart: *7 features · 3 fixes · 2 tasks*
:information_source: _1 PR from a non-teammate: #1456 (ianjamesburke)._
Rules:
- Header:
:rocket: *<new_release> Release Status* :rocket:, then a blank line. - Change count on its own line, bold:
*<N> changes since <last_tag>*. - Recap paragraph immediately under it, wrapped in italics (
_..._around the whole paragraph) — the "what did we do" summary in plain English, not just a list. Compose it so the italics survive: write it as a single line with no hard line breaks (wrap is fine, but don't insert\nmid-paragraph — newlines break the italic span in Slack), and use no literal_anywhere inside (an underscore would close the italics mid-paragraph — rewrite e.g.snake_case/model_idor any underscored identifier without it). - One bullet per change, no per-line emoji:
• <title> (#<number>). Keep the title; trim trailing noise. - Direct commits have no PR number — render them as
• <title>with nothing in parentheses (e.g.• Version bump). List them under their author, after the PRs. - Within each author, list newest PR first; direct commits last. Don't print the feature/fix/task bucket per line.
- Summary block at the end, in this order:
- A bold
:bar_chart:tally line::bar_chart: *<X> features · <Y> fixes · <Z> tasks*. - A blank line.
- If
excluded_prsis non-empty, an italic:information_source:line noting the excluded non-teammate changes (so the omission is visible, not silent). Reference a PR entry by#<num>and a direct-commit entry (wherenumberis null) by itstitle— never print#None. E.g.:information_source: _<N> change(s) from non-teammates excluded: #1456 (ianjamesburke)._
- A bold
- Link the PR numbers if easy (
<url|#1501>), but plain#1501is fine — keep it readable.
Show the composed message to the user and get confirmation before posting (it's an outward-facing post to a shared channel).
Phase 4 — Post to Slack
Post to #release (channel ID C0BDGTGBL4Q) — the team's release channel. This is
the default target; only post elsewhere if the user explicitly names a different
channel. (If the ID ever fails to resolve, fall back to slack_search_channels with
query release to re-find it.)
Only post after the user has confirmed the composed message in Phase 3. Post with
mcp__claude_ai_Slack__slack_send_message. After posting, give the user the channel
name and a link/confirmation.
Checklist
- Gather script run;
/tmp/release_digest.jsonwritten - If
pr_countis 0, reported "nothing unreleased" and stopped - Asked the user for the new release name (used in the title)
- Each PR classified (feature / bug fix / task)
- Grouped by author, recap paragraph written
- Message shown to user and confirmed
- Posted to #release (C0BDGTGBL4Q); confirmation returned to user
Version History
- 480f9d8 Current 2026-08-20 17:37


