Skip to content

Commit 550166d

Browse files
committed
Adopt Sacho for changelog management
Store the current unreleased notes as package-scoped fragments and let Sacho materialize CHANGES.md. Wire Sacho into setup, formatting, and version checks, and document the contributor workflow. Keep the next changelog version aligned with package metadata during normal checks. Parse check-versions options with Optique so CI can skip that check while syncing ephemeral development versions, without allowing --fix to rewrite changes.d/next.txt. https://github.com/fedify-dev/fedify/actions/runs/30435391146/job/90521807212 Assisted-by: Codex:gpt-5.6-sol
1 parent 3dae30c commit 550166d

25 files changed

Lines changed: 1008 additions & 169 deletions

.agents/skills/sacho/SKILL.md

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
---
2+
name: sacho
3+
description: >-
4+
Help a maintainer adopt and operate Sacho, the opinionated changelog manager
5+
that keeps user-facing release notes as one small Markdown fragment per change
6+
under changes.d/ and compiles them into CHANGES.md at release time. Use this
7+
skill whenever a repository has a sacho.toml, a changes.d/ directory, or a
8+
CHANGES.md built from fragments; whenever the user wants to add Sacho to a
9+
project, write or edit a changelog entry for a change they just made, cut a
10+
release, publish release notes, or wire `sacho check` into CI or a pre-commit
11+
hook; whenever forward-porting a bugfix release across maintenance branches or
12+
resolving a changelog merge conflict; and whenever `sacho check` or the
13+
changelog merge driver reports a problem. Also use it any time the user
14+
mentions Sacho, changes.d fragments, or asks how to write a changelog entry
15+
for users instead of generating one from commit messages.
16+
metadata:
17+
type: reference
18+
---
19+
20+
Managing changelogs with Sacho
21+
==============================
22+
23+
Sacho keeps a project's release notes as one small Markdown file per change,
24+
called a fragment, under `changes.d/`. At release time it sorts and compiles the
25+
fragments into a dated section of `CHANGES.md`, then deletes the consumed
26+
fragments. It works in any repository regardless of language, ships as a single
27+
static binary, and does not even assume Git.
28+
29+
The single most important thing to hold onto: Sacho never generates entries from
30+
commit messages, and neither should you. A commit message explains to
31+
collaborators why a change was made; a changelog entry tells users what changed
32+
and what to do when they upgrade. Those are different documents for different
33+
readers. When you draft an entry, do not transform a diff or a `git log` line
34+
into prose. Read the change, work out its effect on someone who only sees the
35+
public surface, and write that. When a change has no user-visible effect, the
36+
honest entry is no entry.
37+
38+
39+
Delegate the details to the live docs
40+
-------------------------------------
41+
42+
The commands and flags below are a working map, not the source of truth. For the
43+
exact syntax of the version installed in this repository, trust `sacho --help`
44+
and `sacho <command> --help` over anything written here or online, because the
45+
docs track the latest release while a project may pin an older binary. Use the
46+
Markdown documentation for concepts, workflow, and reasoning; reach for `--help`
47+
before you depend on a specific flag or output format.
48+
49+
- `https://sacho.dev/llms.txt` lists every page.
50+
`https://sacho.dev/llms-full.txt` is the whole documentation in one file
51+
when you want to read broadly.
52+
- Every page has a `.md` twin. Fetch these directly:
53+
- `https://sacho.dev/why-sacho.md` and `https://sacho.dev/philosophy.md`
54+
for the reasoning, worth reading before you write entries for a project.
55+
- `https://sacho.dev/guide/getting-started.md` to adopt Sacho.
56+
- `https://sacho.dev/guide/everyday-workflow.md` for the per-change loop.
57+
- `https://sacho.dev/guide/releases.md` for cutting releases and
58+
forward-porting.
59+
- `https://sacho.dev/guide/ci-and-hooks.md` for coverage enforcement.
60+
- `https://sacho.dev/guide/version-control.md` for merge drivers and
61+
presets.
62+
- `https://sacho.dev/guide/package-monorepos.md` for deriving sections
63+
from regular package layouts.
64+
- `https://sacho.dev/concepts/fragments.md`,
65+
`https://sacho.dev/concepts/changelog-lifecycle.md`, and
66+
`https://sacho.dev/concepts/sections.md` for the model.
67+
- `https://sacho.dev/reference/commands.md` and
68+
`https://sacho.dev/reference/configuration.md` for exhaustive
69+
references.
70+
- `https://sacho.dev/reference/section-patterns.md` for the capture
71+
grammar and precedence rules.
72+
- `https://sacho.dev/troubleshooting.md` when something misbehaves.
73+
74+
75+
Orient yourself before acting
76+
-----------------------------
77+
78+
Check what the repository already has. Is there a `sacho.toml` at the root, a
79+
`changes.d/` directory, a `CHANGES.md`? Is `sacho` on the `PATH`
80+
(`sacho --version`)? A repository with `sacho.toml` is already set up, so skip
81+
adoption and go to the workflow that fits the request. A repository without it
82+
needs `sacho init` first, but only after confirming the maintainer wants to
83+
adopt Sacho rather than asking about it.
84+
85+
If the binary is missing, the recommended install is
86+
`mise use -g github:dahlia/sacho`; `npm install -g @sacho/sacho` and
87+
`cargo install sacho` work too. Read `getting-started.md` for the full list.
88+
89+
90+
Adopting Sacho in a project
91+
---------------------------
92+
93+
Run `sacho init` from the repository root. It creates `sacho.toml`,
94+
`changes.d/`, and `CHANGES.md`, and in a Git repository registers the merge
95+
drivers. Interactive setup infers issue-link templates from the remote and can
96+
install commit hooks. When an existing changelog is present, it also offers
97+
level-three headings found across the changelog as section candidates and
98+
suggests directories and source path globs. When multiple selected headings
99+
map to one unambiguous sibling package directory, it can store them as one
100+
section pattern. `sacho init --interactive` forces the questions and
101+
`--no-interactive` suppresses them.
102+
103+
Leave old released sections exactly as they are. If the current `Unreleased` or
104+
`Version X` region contains entries, run `sacho import-unreleased` before
105+
creating fragments. Inspect and confirm its normalization diff; in a
106+
noninteractive workflow, review the diff from the refused run before using
107+
`--force`. The command creates deterministic *imported-unreleased.md* fragments
108+
and infers the next version from `Version X`. If there are no current entries,
109+
skip the import and set the version with `sacho next 1.2.0`.
110+
111+
Two configuration decisions matter early, both covered in
112+
`reference/configuration.md`: whether to enforce fragment coverage
113+
(`[check].paths`), and, for a monorepo, whether to define explicit
114+
`[[sections]]` or one `[[section-patterns]]` family so each package's changes
115+
require a fragment in that package's section.
116+
117+
118+
Writing a fragment for a change
119+
-------------------------------
120+
121+
The everyday loop is four commands. Create the fragment, write it, format, and
122+
check:
123+
124+
~~~~ sh
125+
sacho add clear-function # add --section <id> <name> in a sectioned repo
126+
sacho fmt
127+
sacho preview
128+
sacho check
129+
~~~~
130+
131+
`sacho add` creates `changes.d/<name>.md` holding an empty list item and prints
132+
the path. Name the file after the change itself (`clear-function`), never after
133+
an issue or PR number. Topic names make the right follow-up natural: when a
134+
later commit reworks the same feature, you edit the existing fragment instead of
135+
adding a second one that documents a state users never saw.
136+
137+
Keep the fragment in the same commit series as the implementation. Stored in the
138+
repository, it travels with the change through merges, rebases, cherry-picks,
139+
and reverts for free. That coupling is the whole point of fragments, so never
140+
park changelog text in an issue, a wiki, or a draft release note.
141+
142+
143+
Writing entries users can actually read
144+
---------------------------------------
145+
146+
This is the part no tool can do for the maintainer, and where you earn your
147+
keep. Fetch `philosophy.md` when you want the full argument; the working rules:
148+
149+
Address the person upgrading, in the vocabulary of the public surface. A
150+
refactor that touches forty files but changes nothing users can observe is a
151+
real commit and a nonexistent entry. A one-line dependency bump that closes a
152+
vulnerability is a trivial commit and a critical entry.
153+
154+
Write one entry per user-visible change. If a release adds `remove_all()` and a
155+
later commit in the same cycle renames it to `clear()`, the released changelog
156+
contains a single line, `Added clear() function`, not the two-step history. Edit
157+
the existing fragment; never narrate intermediate states that never shipped.
158+
159+
Be concise, and keep internal names out. Private type names, module paths, and
160+
subsystem nicknames tell the reader this document is not for them. If a
161+
mechanism must be mentioned, describe what it does rather than naming a thing
162+
the reader cannot look up.
163+
164+
Start each entry with a past-tense verb: Added, Changed, Deprecated, Fixed,
165+
Removed, Security. Sacho sorts by the entry text, so these verbs fall into the
166+
familiar Keep a Changelog order without any category metadata.
167+
168+
Reference issues and PRs with shortcut links in trailing brackets, house style,
169+
at the end of the first paragraph: `[[#842], [#848]]`. Attribution is inline
170+
prose in the same bracket: `[[#857] by Lee Hoyeon]`. The compiler resolves the
171+
numbers into link definitions using the templates in `[links]`, and `check`
172+
fails on a reference no template can resolve.
173+
174+
Because Sacho asks a human to write, present your drafts to the maintainer for
175+
approval rather than committing prose in their voice unreviewed. You are helping
176+
them write for their users, not automating the writing away.
177+
178+
179+
The fragment file format
180+
------------------------
181+
182+
A fragment is a UTF-8 Markdown file whose body is exactly one top-level
183+
unordered list, with optional YAML frontmatter. Nothing else at the top level:
184+
no headings, no stray paragraphs, no second list. Inside a list item any block
185+
content is fine, including nested lists and fenced code. One item is usual;
186+
related changes from one pull request can share a fragment as several items.
187+
188+
~~~~ markdown
189+
---
190+
priority: -10
191+
---
192+
193+
- Added `clear()` to remove every entry at once. The function accepts an
194+
optional predicate to remove entries selectively. [[#842], [#848]]
195+
~~~~
196+
197+
`priority` (integer, default `0`) is the only frontmatter key; lower sorts
198+
earlier, and most fragments need no frontmatter at all. See
199+
`concepts/fragments.md` for the exact constraints.
200+
201+
202+
Cutting a release
203+
-----------------
204+
205+
Read the compiled result as a user would before you freeze it:
206+
207+
~~~~ sh
208+
sacho next 1.2.0
209+
sacho preview
210+
sacho check
211+
~~~~
212+
213+
In the preview, merge entries that describe the same change, drop development
214+
history, and confirm each entry names public behavior. Then compile:
215+
216+
~~~~ sh
217+
sacho release --next 1.3.0 # reads the version from changes.d/next
218+
sacho release 1.2.0 --next 1.3.0 # or state the version explicitly
219+
sacho release 1.2.0 --date 2026-07-19 --next 1.3.0 # when the date must be fixed
220+
~~~~
221+
222+
`release` stamps the dated section, deletes the consumed fragments, and sets the
223+
next version. The first release may proceed without fragments when the changelog
224+
has no released version sections. Later releases refuse to proceed when the
225+
fragments compile to nothing unless `--allow-empty` is passed. Empty list items
226+
and HTML comments are scaffolding, not release notes, and scaffold-only
227+
fragments require the explicit option even on the first release. Commit the
228+
changed `CHANGES.md`, the removed fragments, and the updated `changes.d/next`
229+
together.
230+
231+
232+
Publishing release notes
233+
------------------------
234+
235+
After the release commit, `sacho show` prints one frozen section:
236+
237+
~~~~ sh
238+
sacho show 1.2.0
239+
sacho show 1.2.0 --skip-heading --no-word-wrap --output-file release-notes.md
240+
~~~~
241+
242+
A tag-triggered GitHub Actions job can hand that file to the GitHub CLI:
243+
244+
~~~~ sh
245+
version="${GITHUB_REF_NAME#v}"
246+
sacho show "$version" --skip-heading --no-word-wrap --output-file release-notes.md
247+
gh release create "$GITHUB_REF_NAME" --notes-file release-notes.md
248+
~~~~
249+
250+
`show` reads only released sections; it never compiles current fragments.
251+
252+
253+
Enforcing coverage in CI and hooks
254+
----------------------------------
255+
256+
Configure the paths whose changes require a fragment, then let a machine catch
257+
the omissions reviewers miss:
258+
259+
~~~~ toml
260+
[check]
261+
paths = ["src/**", "packages/**"]
262+
~~~~
263+
264+
Locally, `sacho check --staged` inspects the Git index;
265+
`sacho init --install-hook` installs a pre-commit hook chain. In CI, check a
266+
branch commit-by-commit against its base:
267+
268+
~~~~ sh
269+
sacho check --base origin/main
270+
~~~~
271+
272+
Make sure the checkout history includes the base revision; a shallow clone that
273+
omits it cannot support the comparison. A commit that genuinely needs no entry
274+
opts out with a `Changelog: none` trailer in its message (or the
275+
`[skip changelog]` family of substrings). The exemption applies only to the
276+
commit that carries it. Details in `guide/ci-and-hooks.md`.
277+
278+
279+
Forward-porting a bugfix release across maintenance branches
280+
------------------------------------------------------------
281+
282+
The scenario: a fix lands on `1.1-maintenance`, `1.1.5` ships, and the fix needs
283+
to reach `1.2-maintenance` and `main`. Two paths, and which one you are on
284+
decides the work.
285+
286+
Merge before releasing, and there is nothing to do. If you merge
287+
`1.1-maintenance` into `1.2-maintenance` while the fix's fragment still exists,
288+
the VCS carries the fragment along, and each branch later consumes its own copy.
289+
Prefer this ordering when you can; it needs no Sacho command.
290+
291+
Merge the release tag, and the fragment arrives already consumed. The incoming
292+
`1.1.5` release commit deleted the fragment, so the merge brings only the frozen
293+
`1.1.5` section. The merge driver inserts that section at its version position
294+
in `CHANGES.md`, keeps the receiving branch's `changes.d/next`, and prints a
295+
`sacho carry` hint on stderr. Now it is policy, not mechanics: if the receiving
296+
branch's own next release should also list the fix, run `sacho carry 1.1.5` to
297+
decompile that section back into `carried-from-1.1.5.md` fragments, edit them
298+
if this branch presents the change differently, and treat them as ordinary
299+
unreleased entries. If the imported `1.1.5` section is enough, do nothing.
300+
301+
`carry` is idempotent by design: it always writes the same
302+
`carried-from-<version>.md` name and overwrites any file already there. That is
303+
safe on a fresh carry, but if you have already edited those carried fragments,
304+
re-running `carry` discards your edits, so check for existing
305+
`carried-from-*.md` files before repeating it.
306+
307+
Under Jujutsu there is no per-path merge driver, so after resolving a concurrent
308+
fragment merge run `sacho sync --force` and inspect the result.
309+
`guide/releases.md` and `guide/version-control.md` cover both.
310+
311+
312+
When something goes wrong
313+
-------------------------
314+
315+
`sacho check` reports across three layers: fragment validity and formatting,
316+
agreement between the materialized unreleased region and the fragments, and
317+
fragment coverage for changed paths.
318+
319+
A formatting or drift failure is mechanically repairable. `sacho check --fix`
320+
runs `fmt` and re-syncs the generated region; it will not write prose. If
321+
`CHANGES.md` was hand-edited, remember that fragments are the source of truth:
322+
move the edited text into a fragment, then `sacho sync`. Sacho asks before
323+
discarding hand edits, and `sacho sync --force` skips that prompt, so read the
324+
diff first.
325+
326+
A missing-fragment failure names the affected section and suggests `sacho add`.
327+
Either write the entry or, if the commit truly has no user-visible effect, use
328+
the `Changelog: none` exemption. For anything stranger, fetch
329+
`troubleshooting.md`.

.github/actions/determine-version/action.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@ runs:
5959
6060
- name: Sync versions across packages
6161
shell: bash
62-
run: mise run check-versions --fix
62+
run: mise run check-versions --fix --skip-next

.hongdown.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ exclude = [
1010
".github/copilot-instructions.md",
1111
"AGENTS.md",
1212
"CLAUDE.md",
13+
"changes.d/**",
1314
"GEMINI.md",
1415
"WARP.md",
1516
"packages/fedify/src/cfworkers/**",

0 commit comments

Comments
 (0)