Automating Token Changelogs with semantic-release
Part of Versioning & Semantic Release for Tokens. This page walks through wiring semantic-release to a design-token package so every merge to main automatically computes a semver bump, writes a changelog entry naming which tokens changed, and publishes the package to npm — without a human deciding what version to cut.
Prerequisites
Before wiring semantic-release to your token package, confirm the following:
- Token package structure: your design tokens live in a standalone npm package (e.g.
@acme/tokens) with its ownpackage.json, separate from the consuming application repos. - Commit convention already enforced: commitlint + husky (or an equivalent hook) rejects any commit that does not follow Conventional Commits (
type(scope): subject). semantic-release reads these commits to decide the bump; non-conforming commits are invisible to it. - CI with write access: your GitHub Actions (or equivalent) runner has an
NPM_TOKENsecret for publishing and aGITHUB_TOKENwith write permission on the repo so semantic-release can push the generated changelog commit and tag. - Node 18+ and semantic-release 24+: earlier versions lack the plugin API surface used in step 4.
- Baseline snapshot file: a committed
token-snapshot.json(generated bystyle-dictionary build) that the custom analyzer can diff against the previous release tag. See the Token Scaling, Validation & CI Pipelines pillar for context on build pipelines.
Step-by-step implementation
Step 1: Install semantic-release and required plugins
npm install --save-dev \
semantic-release \
@semantic-release/changelog \
@semantic-release/git \
@semantic-release/npm \
@semantic-release/github \
conventional-changelog-conventionalcommits
The standard plugin set handles: reading commits, computing the version, writing CHANGELOG.md, publishing to npm, and creating a GitHub release with release notes. The @semantic-release/git plugin commits the updated CHANGELOG.md and package.json back to the branch so the changelog is part of the repo history, not just the GitHub release body.
Why this works: semantic-release is fully plugin-driven; each plugin runs in a declared lifecycle phase (verifyConditions → analyzeCommits → generateNotes → prepare → publish → success). Layering plugins means each concern stays isolated and testable.
Step 2: Map token change types to commit conventions
Agree on a scope convention before wiring the tool. Inconsistent scopes produce wrong bumps.
| Change type | Commit format | Bump |
|---|---|---|
| Token removed or renamed | feat(tokens)!: remove --ds-color-feedback-warning-muted\n\nBREAKING CHANGE: consumers must migrate to --ds-color-feedback-warning-subtle |
major |
| New token added | feat(tokens): add --ds-spacing-layout-hero |
minor |
| Value change (no name change) | fix(tokens): correct --ds-color-action-primary hsl value |
patch |
| Token file refactor, no output change | refactor(tokens): split primitives into separate file |
none |
| Documentation / comment only | docs(tokens): annotate elevation scale |
none |
Enforce this table in your team’s contribution guide. A renamed token that is shipped as fix will produce only a patch bump even though it breaks every consumer that referenced the old name — that is the most common source of accidental breaking releases in token repos.
Step 3: Write the base .releaserc.json
{
"branches": ["main"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{ "breaking": true, "release": "major" },
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "refactor", "release": false },
{ "type": "docs", "release": false },
{ "type": "chore", "release": false }
]
}
],
"./scripts/token-notes-generator.js",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"
]
}
The [skip ci] trailer on the release commit prevents the pipeline from retriggering itself when semantic-release pushes the changelog commit. Without it you get an infinite loop on some CI systems.
Why this works: The releaseRules array is evaluated top-to-bottom; the first matching rule wins. Placing breaking: true first ensures that a fix!: commit (fix with a breaking footer) still produces a major, not a patch.
Step 4: Write the custom token notes generator
The built-in @semantic-release/release-notes-generator lists commit messages. That is not specific enough for a token package — consumers need to see which exact tokens changed. Create scripts/token-notes-generator.js:
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
/**
* semantic-release plugin: generateNotes phase.
* Diffs the compiled token snapshot against the previous release tag
* and surfaces the changed token names in the release notes.
*/
export async function generateNotes(pluginConfig, context) {
const { nextRelease, lastRelease, logger } = context;
// Read current snapshot produced by the build step
const current = JSON.parse(readFileSync("token-snapshot.json", "utf8"));
// Fetch the snapshot from the previous tag (empty object if no prior release)
let previous = {};
if (lastRelease.version) {
try {
const raw = execSync(
`git show ${lastRelease.gitTag}:token-snapshot.json`,
{ encoding: "utf8" }
);
previous = JSON.parse(raw);
} catch {
logger.warn("Could not read snapshot from previous tag — treating all tokens as new");
}
}
const added = [];
const removed = [];
const changed = [];
for (const [key, value] of Object.entries(current)) {
if (!(key in previous)) {
added.push(key);
} else if (previous[key] !== value) {
changed.push({ token: key, from: previous[key], to: value });
}
}
for (const key of Object.keys(previous)) {
if (!(key in current)) {
removed.push(key);
}
}
const lines = [
`## Token changelog — ${nextRelease.version}`,
"",
];
if (removed.length) {
lines.push("### Removed tokens (breaking)");
for (const t of removed) lines.push(`- \`${t}\``);
lines.push("");
}
if (added.length) {
lines.push("### New tokens");
for (const t of added) lines.push(`- \`${t}\``);
lines.push("");
}
if (changed.length) {
lines.push("### Value changes");
for (const { token, from, to } of changed) {
lines.push(`- \`${token}\`: \`${from}\` → \`${to}\``);
}
lines.push("");
}
return lines.join("\n");
}
token-snapshot.json is a flat key-value map of every resolved token name to its final output value. If you use Style Dictionary, generate it in a custom formatter:
// style-dictionary.config.js (excerpt)
export default {
platforms: {
snapshot: {
transformGroup: "css",
buildPath: "./",
files: [
{
destination: "token-snapshot.json",
format: "custom/flat-snapshot",
},
],
},
},
};
// formatters/flat-snapshot.js
export default function flatSnapshot({ dictionary }) {
const out = {};
for (const token of dictionary.allTokens) {
out[`--${token.name}`] = String(token.value);
}
return JSON.stringify(out, null, 2);
}
Why this works: semantic-release calls the plugin’s generateNotes export in the same lifecycle phase as @semantic-release/release-notes-generator. Because both are listed in plugins, they run sequentially and their outputs are concatenated into the final release body. The diff against the previous git tag is deterministic regardless of branch topology because semantic-release always resolves the exact tag for lastRelease.
Step 5: Commit the initial snapshot and update CI to regenerate it
The snapshot must be committed and must be regenerated on every build before semantic-release runs. Add it to .gitignore’s exclude list (or explicitly include it if your .gitignore glob-excludes build artifacts):
# make sure the snapshot is tracked, not gitignored
git add token-snapshot.json
git commit -m "chore(tokens): add initial token snapshot for semantic-release diff"
Step 6: Wire everything into CI
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # semantic-release needs full git history
persist-credentials: true
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: npm ci
- name: Build tokens and regenerate snapshot
run: npm run build:tokens # your style-dictionary build command
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release
fetch-depth: 0 is non-negotiable. semantic-release walks the full commit graph to find the previous release tag; a shallow clone produces the error ENOTINHISTORY and exits without releasing.
Why this works: The build step runs before semantic-release, so token-snapshot.json reflects the current source at the moment of release. The diff in token-notes-generator.js then compares this fresh snapshot against the snapshot baked into the previous tag.
Step 7: Test with a dry run before merging
Add a dry-run job triggered on pull requests so engineers can see the computed version and changelog entry without publishing:
# .github/workflows/release-dry-run.yml
name: Release dry-run
on:
pull_request:
branches: [main]
jobs:
dry-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Build tokens
run: npm run build:tokens
- name: Dry-run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release --dry-run --no-ci
The --dry-run flag skips the publish and success phases. The log output shows the computed next version and the full release notes, including the token diff section from your custom plugin.
Verification
After merging a commit that follows the convention, open the CI run for the release workflow and look for these lines in the Release step output:
[semantic-release] › ℹ The next release version is 1.3.0
[semantic-release] › ✔ Created tag v1.3.0
[semantic-release] › ✔ Prepared @semantic-release/changelog
[semantic-release] › ✔ Published @acme/tokens@1.3.0 to npm registry
[semantic-release] › ✔ Added release notes to GitHub release
Check the GitHub release page for the repository. The release body should contain the ## Token changelog section produced by your custom plugin, listing the specific token names that changed. If the section is absent, the generateNotes export in token-notes-generator.js did not run — confirm the file path in .releaserc.json matches the actual script location.
For the dry-run workflow, the PR check logs should show the computed version without any publish step completing. If you see [semantic-release] › ℹ There are no relevant changes, so no new version is released, the commits in the PR do not match any release rule — check that the commit type and scope conform to the table in step 2.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No release triggered after merge | Commits use non-matching types (chore, refactor, docs) or the message format is malformed (missing colon, wrong scope syntax) |
Run npx commitlint --from HEAD~5 locally to validate the last five commits; fix with git rebase -i before merging |
| Wrong bump produced (patch instead of major) | A removed or renamed token was committed with fix(tokens): instead of a BREAKING CHANGE footer |
Amend the commit or add a follow-up feat(tokens)!: ... commit; educate the team on the mapping table in step 2 |
| Token diff missing from release notes | The token-snapshot.json file was not committed, or the path in scripts/token-notes-generator.js does not match the build output path |
Confirm the file exists in the repo at the expected path: git show HEAD:token-snapshot.json |
ENOTINHISTORY error from semantic-release |
fetch-depth is not set to 0 in the checkout step, producing a shallow clone |
Set fetch-depth: 0 in actions/checkout@v4 |
| semantic-release pushes a release commit that re-triggers CI | The release commit message is missing [skip ci] |
Add [skip ci] to the message template in the @semantic-release/git plugin config (shown in step 3) |
Migration note from manual versioning
If the token package currently has hand-bumped versions in package.json and a manually maintained CHANGELOG.md, introduce semantic-release in two phases to avoid disrupting consumers.
Phase 1 — shadow mode. Add the dry-run CI job (step 7) to every PR for two to four weeks. Do not enable the release workflow yet. Use this period to train the team on commit conventions and observe whether the computed bumps match what would have been decided manually.
Phase 2 — cut over. Once the dry-run output is consistently correct, enable the release workflow. Set the initial version in package.json to the current production version so semantic-release’s first automated release increments from a known baseline rather than resetting to 1.0.0. Commit [skip ci] to avoid triggering a spurious release on the cutover commit itself.
For versioning theme contracts across brands, a separate package per brand allows each to follow its own release cadence while sharing the same semantic-release configuration via a shared releaserc.base.js that individual brand packages spread-import.
Related
- Versioning & Semantic Release for Tokens — parent: the broader strategy for versioning a token package, including pre-release channels and monorepo considerations
- Token Scaling, Validation & CI Pipelines — the full pipeline context: schema validation, orphan detection, and Stylelint enforcement that feed into the same CI job as this release workflow
- Versioning Theme Contracts Across Brands — applying the same commit-convention discipline to per-brand theme contracts in a white-label system