How-to › Document and support developers

How to ship a changelog inside your published package#

Put the changelog in the published artifact, and check in CI that it ships and that its newest entry matches the version being released.

Audience
Library maintainer
Level
beginner
Topic
Ship docs with the code
Languages
TypeScript and JavaScript
Verified

An upgrade breaks a build in an air-gapped environment, and the only thing the engineer has is the installed tree. The changelog is on a website they cannot reach. So are the release notes. The package they can read carries a README written for a version from two years ago.

What you get

You will end up with the changelog inside the published artifact. A check then fails the release when the file is absent, or when its newest entry is not the version being published. This is for you if you publish a library and want an upgrade to be diagnosable offline.

Short answer

List the changelog in the package’s file allowlist so it lands in the installed tree, and add a check that compares its newest version heading with the version being published. A changelog that lives only on a website is unreachable from a machine that has the package and no network, which is where most upgrade questions are asked.

You will need

Node 22 or later, and a package you publish. The allowlist is the files field. A handful of entries such as the manifest and the README ship whether or not you list them, which is what makes a missing changelog easy to overlook.1 The Keep a Changelog format gives the version headings the check below reads.2

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A changelog file in the packageAny published library, because it travels with the codeBytes in every install, and a file the release has to keep currentThe history is genuinely uninteresting to consumers
A docs site sectionYou want the history rendered, linked and searchableA network and a browser, neither of which an upgrade debugging session hasThe reader is offline or working from an installed tree
Generated notes in the registry descriptionYou already generate notes and want them where people browseA page per version, and no way to read two versions side by sideSomebody is comparing several versions at once
Release notes on the forge onlyAnnouncements, where a formatted page and a subscription list helpNothing in the artifact, so an offline reader has no history at allThe package can be installed without visiting the forge

These are not exclusive and the ordering is the point. Ship the file, then render it wherever else you like. Of the several renderings built from one source, the one in the artifact is the one that cannot go missing. The file in the package is the copy that is guaranteed to exist alongside the code that is actually installed, and every other copy is a convenience built from it.

The version check is what keeps the file accurate over time. A changelog nobody verifies drifts from the releases within a few months. The usual cause is a patch that went out without an entry. A reader who notices that once stops trusting the file entirely.

Put it in the allowlist

The allowlist is a decision to include, and anything missing from it is silently absent.

{
  "name": "@meterco/sdk",
  "version": "1.5.0",
  "description": "Client for the Meters API",
  "files": ["src", "CHANGELOG.md"],
  "repository": { "type": "git", "url": "https://github.com/meterco/sdk-js" },
  "main": "src/index.js"
}

Keep the repository field too. The shipped changelog is the newest entries and the repository is the whole history, so a reader who needs more than the file has somewhere to go.

Check what ships rather than what you meant to ship. The allowlist interacts with ignore files and with defaults, and the only reliable answer is to resolve it the way a publish does.

Compare the newest entry with the version

One comparison, run before the publish.

const top = /^##\s+v?(\d+\.\d+\.\d+)/m.exec(text)
if (!top) problems.push('the changelog has no version heading')
else if (top[1] !== manifest.version) {
  problems.push(`the changelog's newest entry is ${top[1]}, the package is ${manifest.version}`)
}

Run it in the release job, before the publish rather than after. A published version cannot be replaced, so a changelog that lags is a defect that stays in the registry forever.3

The check also catches the opposite mistake: a changelog updated for a version nobody released, which happens when a release is abandoned halfway and the entry is left behind.

Check it worked

Audit a package that does this correctly and one that does not.

node demo.mjs
package/
  ships     CHANGELOG.md, README.md, package.json, src/index.js, src/meters.js
  newest    1.5.0
  problems  none
stale-package/
  ships     package.json, src/index.js
  newest    -
  problems  no CHANGELOG ships with the package

The second package has a changelog on disk. It is not in the allowlist, so it never reaches anyone who installs the package, even though nothing about the repository looks wrong. That is the failure this check exists to catch, and it is invisible in every view except the published artifact.

The file list is worth reading on its own. Tests are excluded here, which is deliberate. A package that ships its whole tree by accident carries fixtures, coverage output, and whatever else was in the directory at publish time. That is bytes on every install and, sometimes, a credential.

node --test willship.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 128.384339

When it goes wrong

The changelog ships and is empty. A generator wrote it into a path the allowlist does not cover, so the shipped copy is an older file with the same name. Check the shipped bytes, not the repository. Installing the published package into a temporary directory once per release is the crudest version of this check and it catches most of it.

The check fails on a pre-release. The version carries a suffix the heading pattern does not match. Decide how pre-releases appear in the changelog and make the pattern match that. Skipping the check on a pre-release is the other defensible answer, as long as it is a decision rather than an accident.

The file grows to thousands of lines. Every release since the beginning is in one file. Keep the recent entries in the shipped file and archive the rest, with a line saying where they went. A file that stops at the last major version, plus a link to the rest, reads better than four years of history nobody scrolls through.

Two packages in one repository share a changelog. Each installs a file describing the other’s releases as well. Give every published package its own, generated from the commits that touched it.

When not to do this

Do not ship a changelog generated from every commit. A file listing four hundred dependency bumps is worse than no file, because a reader has to scan it before concluding it says nothing.

Do not ship it and stop maintaining the rendered version. The two copies are the same content and the same source, and letting the website drift teaches people not to trust either.

Do not treat the shipped file as the announcement. It answers what changed for somebody already holding the package, and telling people a release exists is a different job, with a different audience and a different cadence.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The npm documentation lists five things that ship regardless of the allowlist: the manifest, a README, a LICENSE or LICENCE, the file named in main and the files named in bin. It then lists seventeen that are ignored by default, among them CVS, .svn, .hg and .wafpickle-N, names that date the list. Six of the seventeen cannot be included even by naming them. A changelog appears in neither list, which is the whole reason for this page. ↩︎ Back to text

  2. Keep a Changelog opens with the line that nobody should let their friends dump git logs into changelogs. It then keeps its own changelog, in its own format, at the foot of the page. Version 1.1.0 is dated 2019-02-15, in the ISO 8601 form the format prescribes. The Unreleased section arrived in 0.0.5, on 2014-08-09, the same day as 0.0.4. The format is MIT licensed and offered in 28 languages. ↩︎ Back to text

  3. The rule belongs to the registry, not to the changelog. The guide to npm unpublish allows a package to be removed within 72 hours of its first publish, and after that only under the conditions the policy sets out. Either way, a name and version pair is unique and cannot be reused by removing it and publishing again, so taking the version down does not give it back. The guide recommends a minor version update instead, which is a new entry in the changelog and a fresh chance to get it wrong. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.