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
5 changes: 3 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@

---

- [ ] `CHANGELOG.md` updated under `## Unreleased` — or this change is
internal-only / test-only / docs-only (apply the `no-changelog` label).
- [ ] Changelog fragment added — `changelog.d/<slug>.<breaking|added|changed|fixed>.md`
(see `changelog.d/README.md`) — or this change is internal-only /
test-only / docs-only (apply the `no-changelog` label).

<!-- AI-assisted contributions are welcome and normal here — see
CONTRIBUTING.md for the attribution convention (footer + Co-Authored-By). -->
11 changes: 8 additions & 3 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,20 @@ jobs:
with:
fetch-depth: 0

- name: Require CHANGELOG.md update when src/ changes
- name: Require changelog fragment when src/ changes
run: |
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
changed=$(git diff --name-only "$base...$head")
# Fragments must be *added* — a deleted or renamed fragment also
# appears in --name-only and must not satisfy the check.
added=$(git diff --name-only --diff-filter=A "$base...$head")
echo "Changed files:"
echo "$changed"
if echo "$changed" | grep -q '^src/' && ! echo "$changed" | grep -qx 'CHANGELOG.md'; then
echo "::error::This PR touches src/ but not CHANGELOG.md. Add an entry under 'Unreleased' (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only."
if echo "$changed" | grep -q '^src/' \
&& ! echo "$added" | grep -Eq '^changelog\.d/[^/]+\.(breaking|added|changed|fixed)\.md$' \
&& ! echo "$changed" | grep -qx 'CHANGELOG.md'; then
echo "::error::This PR touches src/ but carries no changelog entry. Add a fragment changelog.d/<slug>.<breaking|added|changed|fixed>.md (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only."
exit 1
fi
echo "OK"
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ permissions:

jobs:
build-and-test:
name: Build & Test (${{ matrix.os }})
name: Build & Test (${{ matrix.os }}, node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
# Both platforms: a package-lock.json regenerated over an existing
# node_modules tree records only that machine's native binaries, so a
# lock made on macOS breaks Linux installs and vice versa. Each runner
# catches the lock the other's platform produced. Tests run on ubuntu.
# Node: the engines floor and current LTS — stdio flushing semantics
# around process exit differ between versions and platforms.
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 24]

steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
Expand All @@ -30,7 +33,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
node-version: ${{ matrix.node }}

# npm ci installs the lock verbatim: npm >= 11 'npm install' silently
# re-resolves platform packages missing from the lock, so only npm ci
Expand All @@ -44,6 +47,7 @@ jobs:
- name: Build
run: npm run build

# Tests run on both platforms: process/stdio behaviour differs (pipes
# are asynchronous on macOS), and ubuntu-only runs have missed that.
- name: Test
if: matrix.os == 'ubuntu-latest'
run: npm test
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Notable changes to `@animalabs/context-manager`, loosely following
[Keep a Changelog](https://keepachangelog.com/). Entries land with the change
that causes them — see [CONTRIBUTING.md](CONTRIBUTING.md#changelog).
that causes them, as fragment files in [`changelog.d/`](changelog.d/) that are
folded into a version section at release time — see
[CONTRIBUTING.md](CONTRIBUTING.md#changelog).

Releases up to and including 0.6.2 predate this file; for their contents see
`git log` and the
Expand Down
70 changes: 47 additions & 23 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ plus, when applicable, **Not verified**, **Out of scope**, and
not mere presence — a test that can't fail on the unfixed code will be
called out, and a compression test whose fixture is too small to trigger
the path it claims to cover is the local specialty of that failure.
- **Changelog entry** under `## Unreleased` for anything behavior-affecting
- **Changelog fragment** in `changelog.d/` for anything behavior-affecting
(see below).

Conventional-commit-style titles (`feat(strategy): …`, `fix(compression): …`)
Expand Down Expand Up @@ -88,39 +88,63 @@ that don't fail on unfixed code, or with claims the branch itself disproves.

## Changelog

`CHANGELOG.md` keeps a standing `## Unreleased` section with
`### Breaking` / `### Added` / `### Changed` / `### Fixed` subsections
(loosely [Keep a Changelog](https://keepachangelog.com/)).

- **The entry lands with the change** — same commit, or at least the same
Changelog entries land as **fragment files** in
[`changelog.d/`](changelog.d/) — one file per change — and are folded into
`CHANGELOG.md` (loosely [Keep a Changelog](https://keepachangelog.com/)) at
release time. One file per change is what keeps concurrent work from
conflicting: when every PR edited the same `## Unreleased` section, any PR
that outlived another merge hit a conflict in `CHANGELOG.md`; distinct files
never do.

- **Format:** `changelog.d/<slug>.<breaking|added|changed|fixed>.md`, a flat
file directly in `changelog.d/`, containing one or more markdown bullets
(`- …`) written exactly as they should appear in `CHANGELOG.md`:
continuation lines indent two spaces, nested bullets are fine, headings
and horizontal rules are refused (even indented — a heading inside a
fragment would corrupt the section structure). The slug just has to be
unique among pending fragments and filesystem-safe — the PR number works,
and so does the branch name with `/` replaced by `-`
(`54-view-composition.added.md`, `fix-retry-backoff.fixed.md`). The release
script scans the directory fail-closed: a subdirectory, an unrecognized
category suffix, or any other stray file aborts the release rather than
silently stranding an entry.

- **The fragment lands with the change** — same commit, or at least the same
PR. This binds direct pushes to `main` just as much as PRs. On PRs, CI
enforces it softly: touching `src/` without touching `CHANGELOG.md` fails
the `changelog` check unless the `no-changelog` label is applied.
enforces it softly: touching `src/` without adding a fragment (or editing
`CHANGELOG.md`) fails the `changelog` check unless the `no-changelog`
label is applied.
- **What needs an entry:** anything a host or strategy author would notice —
strategy behavior and option types, compression triggers and thresholds,
token budgeting and holdback, pinning, cache-breakpoint placement, what a
compile emits, public exports, defaults. Internal refactors, test-only,
and docs-only changes don't.
- **Breaking entries are audience-scoped.** Name the audience in the heading
(`### Breaking (host operators only)`) and cover: **who needs to act**,
- **Breaking entries are audience-scoped.** Open the bullet by naming who
needs to act (`- **Host operators:** …`) and cover: **who needs to act**,
**migration**, and **unchanged** (what readers might fear broke but
didn't). Config that becomes *required* is the sharpest case here — an
option that starts throwing when unset breaks every recipe that omitted
it, and that is exactly what the "who needs to act" line is for.
- **Keep one `## Unreleased` heading.** Add entries under the existing one;
don't open a second. Only the first is cut at release time, so entries
filed under a later heading are silently never released — the release
script refuses to run if it finds more than one.
- **Editing `## Unreleased` in `CHANGELOG.md` directly still works** and is
merged with the fragments at release time — it remains the right place to
restructure pending entries, and the escape hatch for anything the
fragment format can't express (e.g. an audience-qualified
`### Breaking (host operators only)` heading, which `breaking` fragments
will then join). Keep one `## Unreleased` heading — the release script
refuses more than one, since only the first is ever cut.
- **Releases** (maintainers): `npm version <patch|minor|major>` does the
whole cut — the `version` hook retitles `Unreleased` to
`## X.Y.Z — YYYY-MM-DD` (keeping a fresh `Unreleased` above it, and
refusing to release when there are no entries), then npm commits and tags.
`git push --follow-tags` triggers CI, which refuses a tag with no matching
changelog section, publishes `@animalabs/context-manager` to npm, and
creates the GitHub release with that section as its notes. The two release
jobs are independent: some consumers run github-clone checkouts, so
release notes must exist even when npm publish fails. Version bumps are a
maintainer release-time action, not part of feature PRs.
whole cut — the `version` hook folds the pending fragments plus any
entries filed directly under `Unreleased` into `## X.Y.Z — YYYY-MM-DD`
(subsections emitted in `### Breaking` / `### Added` / `### Changed` /
`### Fixed` order), deletes the consumed fragments, keeps a fresh empty
`Unreleased` above, and refuses to release when there is nothing to
release; npm then commits and tags. `git push --follow-tags` triggers CI,
which refuses a tag with no matching changelog section, publishes
`@animalabs/context-manager` to npm, and creates the GitHub release with
that section as its notes. The two release jobs are independent: some
consumers run github-clone checkouts, so release notes must exist even
when npm publish fails. Version bumps are a maintainer release-time
action, not part of feature PRs.

## Building and testing

Expand Down
28 changes: 28 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Pending changelog fragments

One file per change, so concurrent branches never conflict the way shared
`CHANGELOG.md` edits do. At release time `npm version` folds every fragment
here into the new version section of `CHANGELOG.md` and deletes it.

**Name:** `<slug>.<breaking|added|changed|fixed>.md`, as a flat file directly
in this directory. The slug just has to be unique among pending fragments and
filesystem-safe: the PR number works, and so does the branch name with `/`
replaced by `-` (`54-view-composition.added.md`, `fix-retry-backoff.fixed.md`). The
release script refuses subdirectories and any other stray file here, so a
misplaced entry fails the release loudly instead of being left out.

**Content:** one or more markdown bullets, exactly as they should appear in
`CHANGELOG.md`. Continuation lines indent two spaces (nested bullets are
fine); headings and horizontal rules are refused even when indented, since a
heading inside a fragment would corrupt the section structure:


```markdown
- `viewFilter` composes a strategy's view of the message store without
forking the chunker (#54). Continuation lines indent two spaces.
```

Breaking fragments open by naming who needs to act:
`- **Host operators:** …`.

See [CONTRIBUTING.md](../CONTRIBUTING.md#changelog) for what needs an entry.
5 changes: 5 additions & 0 deletions changelog.d/changelog-fragments.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Changelog entries now land as per-change fragment files in `changelog.d/`
(`<slug>.<breaking|added|changed|fixed>.md`), folded into the version
section at release time — concurrent PRs no longer conflict in
`CHANGELOG.md`. Editing `## Unreleased` directly still works and is merged
at the same point.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"typecheck": "tsc --noEmit",
"pretest": "npm run build",
"test": "node --test dist/test/*.test.js dist/test/adaptive/*.test.js",
"version": "node scripts/release-changelog.mjs && git add CHANGELOG.md",
"version": "node scripts/release-changelog.mjs && git add CHANGELOG.md changelog.d",
"prepublishOnly": "npm run build"
},
"keywords": [
Expand Down
Loading
Loading