@absolutejs/changelog
v0.7.0
Published
The changelog contract for the AbsoluteJS packages. Changes are written as typed entries the compiler checks, the release gate reconciles them against the package's own published types so a change nobody wrote down cannot ship, and migrations are data rat
Maintainers
Readme
@absolutejs/changelog
The changelog contract for the AbsoluteJS packages.
A changelog is worth having when it can be trusted and acted on. Prose changelogs manage neither: they rot because writing them is a discipline, and they cannot be acted on because "refactored the server options" is a true sentence that does not say which export moved or what to write instead.
This package makes both possible.
- Entries are typed. A change is written as TypeScript, so the compiler insists that a breaking change names the exports it breaks and says how to migrate. The entries that cost somebody an afternoon are the ones that cannot be filed empty.
- The release gate checks them against the package. Before a release goes out, the exports that vanished or changed shape are compared with the exports the entries name. Forget an entry and the release stops. Nobody has to remember.
- Migrations are data. A rename and a move are the overwhelming majority of real breaking changes, and both are mechanical. Written as data they can be applied — by a CLI, an editor, or a hosted upgrade button — without running a line of code that came from a registry.
Adopting it
bun add -d @absolutejs/changelog
bunx absolute-changelog adoptadopt creates changelog/unreleased/, keeps whatever CHANGELOG.md already
said as changelog/history.md, adds changelog.json and CHANGELOG.md to the
package's files, and makes sure your tsconfig compiles the entries — that
last one matters, because an entry nothing compiles is an entry nobody checks.
Then add the gate to the release chain:
"check:package": "bun run typecheck && bun run build && bun run test && absolute-changelog check"Put it after build: the check compares the types you are about to publish
against the ones you published last time, and it needs dist to exist.
Writing a change
One file per change, so two branches adding one do not meet over the same line:
bunx absolute-changelog add --kind added --summary "listen accepts a unix socket path"
bunx absolute-changelog add \
--kind breaking \
--summary "start is now listen" \
--symbol start \
--instruction "Import listen instead of start." \
--rename-to listenWhich writes changelog/unreleased/start-is-now-listen.ts:
import type { Change } from "@absolutejs/changelog";
export const change: Change = {
kind: "breaking",
migration: {
instruction: "Import listen instead of start.",
rename: { from: "start", to: "listen" },
},
summary: "start is now listen",
symbols: ["start"],
};The import is type-only, so an entry has nothing to resolve at run time and the gate works in a checkout with no dependencies installed.
Checked where you type it
An entry can be written against the package's own API, and add scaffolds it
that way when it finds one:
import type * as Api from "../../src/index";
import type { Change } from "@absolutejs/changelog";
export const change: Change<typeof Api> = {
kind: "fixed",
summary: "stops throwing on an empty list",
symbols: ["listen"], // completed from the package's exports
};Suggested rather than required, for two reasons: a removed entry names
something that has just stopped existing, and typeof Api cannot see
type-only exports at all. The gate is the certain half — it reads the
published .d.ts, which carries them.
The kinds
breaking and removed cost a consumer work, and the type refuses them
without symbols and a migration. added, changed, deprecated,
fixed, security and internal do not.
The migrations
| Shape | What it means | Applied |
| ----------------------------- | ----------------------------------------------- | ------- |
| rename: { from, to } | An export kept its meaning and changed its name | yes |
| moved: { symbol, from, to } | An export moved to another entry point | yes |
| resubpath: { from, to } | A whole entry point moved | yes |
| manual: true | Somebody has to read the call | no |
manual is not a failure. A changed meaning is not a rewrite anybody should
automate, and pretending otherwise is worse than saying so.
Releasing
bunx absolute-changelog releaseWorks out the version from the entries — a breaking change moves the minor
below 1.0.0 and the major above it, and a version already on a prerelease
line moves along that line — then writes changelog.json and CHANGELOG.md,
bumps package.json, and deletes the entries it consumed.
--as major|minor|patch|prerelease overrides the inference, --version x.y.z
overrides it entirely, and --dry shows the release without writing anything.
The gate
bunx absolute-changelog check- every entry parses, and the disruptive ones carry what they must;
CHANGELOG.mdis what the entries say it should be — it is generated, and editing it is how the two copies drift;- the version in
package.jsonand the newest release agree; package.jsonstill ships both documents, and nothing is sitting in the entry directory that no release will read;- the entries name every export that moved since the newest published
version — not the one in
package.json, which betweenreleaseandpublishis a version nobody can fetch; - and every migration describes what actually happened: a rename whose destination this version does not export, a rename whose source is still exported, a move to an entry point the package does not have, a removal of something still there.
That last one is what makes an applicable migration safe to apply. A migration that reads correctly and rewrites working code into something that does not compile is the whole risk of automating an upgrade, and it fails the release instead.
--offline skips the two that talk to the registry.
adopt also writes prepublishOnly, so the gate runs however a publish was
started — a release script, a bare npm publish, a CI job. A rule that can be
walked around eventually is.
Reading somebody else's
Anything deciding whether an upgrade is safe reads the published document:
import {
applyMigrations,
migrationsBetween,
publishedChangelog,
} from "@absolutejs/changelog";
const changelog = await publishedChangelog("@absolutejs/example", "2.1.0");
const migrations = changelog
? migrationsBetween(changelog, "1.4.0", "2.1.0")
: [];
const upgraded = applyMigrations(source, {
migrations: migrations.map((at) => at.migration),
packageName: "@absolutejs/example",
});For a package that has not adopted this yet, publishedSurface and
diffSurface compare the published types of two versions instead — less than
a changelog, and much more than nothing.
Licence
MIT.
