Changelog
A changelog is a curated, versioned record of the notable changes in each software release, written by maintainers so users can decide whether to upgrade.
Also known as CHANGELOG.md, change log, NEWS file, HISTORY.md
A changelog is a curated, chronologically ordered file, usually CHANGELOG.md (older projects use NEWS or HISTORY), in which maintainers record the notable changes in each release. Users read it to decide whether an upgrade is safe. It is a claim, not evidence: no major package registry checks that a changelog matches the code that actually ships.
The gap comes from how publishing works. A registry such as npm accepts a tarball from whoever holds a valid publish credential, records its hash and timestamp, and serves it. The changelog lives somewhere else: in a Git repository, on a GitHub release page, or as a copy inside the tarball. Nothing ties any of those to the bytes that npm install runs.
Our position is simple. Treat the changelog as the maintainer's statement of intent, and treat the package diff as the record of what changed. When the two disagree, the diff wins. In 2024 the xz-utils backdoor shipped with a NEWS entry that described the backdoor's crash bug as a routine fix. In March 2026 the axios compromise shipped two malicious versions with no Git tag and no release notes at all. In both cases the changelog said nothing true about what users installed.
What is a changelog in software development?
#A changelog in software development is a human-written summary of what changed between released versions, grouped so a user can scan it quickly. The convention predates Git. The GNU coding standards describe a ChangeLog file of dated blocks, each naming its author and the files and functions touched. That format served maintainers debugging old changes. Modern changelogs serve users deciding whether to upgrade.
The most widely copied modern format is Keep a Changelog, created by Olivier Lacan. Its first guiding principle is blunt: “Changelogs are for humans, not machines.” The spec defines six change types, an Unreleased section at the top, newest release first, an ISO 8601 date on every release, and linkable versions, usually pointing at a compare view.
# Changelog
All notable changes to this project are documented here.
This project adheres to Semantic Versioning.
## [Unreleased]
### Added
- `timeout` option on `Client.request()`.
## [2.4.1] - 2026-10-14
### Security
- Reject CR and LF in header values. 2.0.0 through 2.4.0 allow
header injection. Fixed in 2.4.1; no workaround.
### Fixed
- Close idle HTTP/2 sessions when `timeout` expires.
## [2.4.0] - 2026-09-30 [YANKED]
### Changed
- `retry` defaults to 2 attempts. Yanked: broke idempotent PUTs.
[Unreleased]: https://github.com/acme/http-client/compare/v2.4.1...HEAD
[2.4.1]: https://github.com/acme/http-client/compare/v2.4.0...v2.4.1
[2.4.0]: https://github.com/acme/http-client/compare/v2.3.2...v2.4.0A changelog is not a commit log. A commit records one step in the source tree, written for reviewers. A changelog entry summarizes the user-visible effect of many commits. Release notes are the per-release cousin, usually published on GitHub Releases or a docs site, and often longer and more promotional. Version history is whatever the registry or version control generates automatically. These four records describe the same release, and they routinely disagree.
How do you write a good changelog?
#A good changelog gives every release a dated, linkable section, groups changes by type, and states security impact as precisely as an advisory would. Keep a Changelog's six types cover almost everything: Added, Changed, Deprecated, Removed, Fixed, and Security. The rest is discipline.
- Write an entry for every published version, including patch releases and prereleases that reached the registry.
- Say whether you follow semantic versioning, then mean it. Put every breaking change under
ChangedorRemovedwith the migration step, not buried inFixed. - Write
Securityentries like a security advisory: the advisory ID if one exists, the affected version range, the fixed version, and any workaround. “Security fixes” with no range forces every user to diff the release themselves. - Keep yanked and unpublished versions in the file, marked
[YANKED]. Deleting them erases the one record of what users may have installed. - Tag the release from the same commit that contains its changelog entry, so the entry, the tag, and the build describe one tree.
Automation helps with volume and hurts with quality. Conventional Commits maps fix to a semver PATCH, feat to a MINOR, and a BREAKING CHANGE footer to a MAJOR. Release tools read those prefixes, bump the version, and draft the entry. The trade-off is that a generated changelog inherits commit messages written for reviewers, not users. We prefer generated drafts with a mandatory human edit before release.
If you ship CHANGELOG.md inside your package, keep it current or leave it out. npm does not include it automatically, and a stale copy on disk misleads more than a missing one. As the next section shows, even well-run projects get this wrong.
Why a changelog cannot tell you what shipped
#Start with what npm publish actually does. It packs the files selected by the files field or .npmignore, uploads the tarball with its manifest, and the registry stores it with an integrity hash and a publish time. No Git tag is required. No commit is required. No changelog is required. Provenance is optional and, when present, describes the build, not the changelog.
That leaves three independent records of every release, and only one of them executes.
Figure 1 shows a Git repository at a release tag holding source and CHANGELOG.md, a GitHub release holding release notes, and a publish credential (an npm token or an OIDC trusted-publisher binding). The repository feeds npm pack and a build step, which produces the registry tarball; the credential authorizes the upload. The tarball contains package.json, built bundles such as dist/axios.min.js, library source, and possibly a stale copy of CHANGELOG.md. The registry records its publish time, dist.integrity hash, and publisher. npm install runs the tarball, not the notes. No step checks that the tarball equals the tag. Path A is a publish with no tag or notes (axios 1.14.1). Path B is files that exist only in the tarball (xz 5.6.0). Path C is a changelog entry that explains the payload away (xz 5.6.1).
How far the records drift on an honest release
#We pulled two consecutive axios releases from the registry and compared them with npm diff, which diffs published tarballs rather than Git trees. Anyone can rerun this.
$ npm pack axios@1.13.6 axios@1.14.0
$ npm diff --diff=axios@1.13.6 --diff=axios@1.14.0 --diff-name-only | wc -l
20
$ tar -xzOf axios-1.14.0.tgz package/CHANGELOG.md | grep -m1 '^## '
## [1.13.3](https://github.com/axios/axios/compare/v1.13.2...v1.13.3) (2026-01-20)
$ npm diff --diff=axios@1.13.6 --diff=axios@1.14.0 -- package.json | grep proxy-from-env
+ "proxy-from-env": "^2.1.0"
- "proxy-from-env": "^1.1.0"The CHANGELOG.md shipped inside axios@1.14.0 ends at version 1.13.3, dated January 20, 2026. Four releases in a row, 1.13.4 through 1.14.0, ship a changelog whose newest heading is 1.13.3. The real notes for 1.14.0 live on its GitHub release page, and they are accurate. They describe the proxy-from-env v2 alignment and a fix to the main entry, both of which show up in the package.json diff above.
Two lessons follow. First, the changelog a developer finds in node_modules can be more than two months behind the code next to it, even from a careful team. Second, the diff is mostly machine output. Of the 20 changed files, 12 are bundles or source maps under dist/. Six are library sources, plus package.json and the README. A reviewer who reads only the changelog sees none of it. A reviewer who reads the diff sees minified JavaScript no human audits line by line.
How the axios compromise skipped the changelog entirely
#The axios compromise shows the other failure mode. On March 31, 2026, axios@1.14.1 and axios@0.30.4 were published directly to npm, both release branches poisoned within 39 minutes of each other. Our analysis found the malicious version “does not appear in the project’s official GitHub tags.” The change was minimal, primarily one new dependency, plain-crypto-js@4.2.1, whose postinstall script dropped a remote access trojan. Axios had about 100 million weekly downloads at the time. The lead maintainer later explained that a targeted social engineering campaign gave the attacker his npm and GitHub sessions, a textbook maintainer account takeover.
There was no changelog entry and no release note to read. The absence was the signal, and only someone comparing registry metadata with Git would have seen it. The registry still keeps the evidence. npm removed both versions but retains their publish timestamps, as we documented while investigating a later incident:
$ npm view axios time --json | grep '"1\.14\.'
"1.14.0": "2026-03-27T19:01:40.915Z",
"1.14.1": "2026-03-31T00:21:58.168Z",
$ npm view axios versions --json | grep '"1\.14\.'
"1.14.0",Attackers write changelogs too
#The axios attacker skipped the changelog. The xz-utils attacker used it. The XZ Utils 5.6.0 and 5.6.1 release tarballs were created and signed by “Jia Tan,” the account behind the backdoor. The backdoor's trigger lived in a modified build-to-host.m4 that existed only in those tarballs. Andres Freund, who found it, wrote on March 29, 2024: “That line is not in the upstream source of build-to-host, nor is build-to-host used by xz in git.”
The 5.6.1 release, dated March 9, 2024, fixed bugs in the backdoor itself, and its NEWS entry needed to describe those fixes as something else. Here it is from the project's NEWS file, with the annotation the real maintainer added after discovery:
5.6.1 (2024-03-09)
IMPORTANT: This fixed bugs in the backdoor (CVE-2024-3094) (someone
had forgot to run Valgrind).
* liblzma: Fixed two bugs relating to GNU indirect function (IFUNC)
with GCC. The more serious bug caused a program linked with
liblzma to crash on start up if the flag -fprofile-generate was
used to build liblzma. The second bug caused liblzma to falsely
report an invalid write to Valgrind when loading liblzma.Read without the first paragraph, the entry is a model changelog: specific, scoped, and plausible. It shipped in the attacker's signed release tarball, and it described the backdoor's crash and Valgrind noise as IFUNC bugs. A changelog is only as trustworthy as the account that writes it, and in a supply chain attack that account is the attacker's.
Signing and provenance do not close this gap. In the TanStack compromise in May 2026, 84 npm package artifacts gained a new 2.3 MB obfuscated file, router_init.js, and were published through the project's own OIDC trusted-publisher binding after attacker code ran in its GitHub Actions workflow. Code running in that workflow can also mint valid Sigstore attestations. Package provenance can tell you which workflow published a tarball. It cannot tell you the workflow was hijacked, and no changelog mentioned router_init.js.
Changelog vs release notes vs package diff
#Every release produces several records. They differ in who writes them and whether they survive a compromised publisher.
| Record | Written by | Describes | Survives a compromised publisher? | Use it for |
|---|---|---|---|---|
| Changelog (CHANGELOG.md) | Maintainer, by hand or from commits | Intended user-visible changes | No. The attacker can skip, copy, or write it. | Deciding whether to upgrade |
| Release notes (GitHub Releases) | Maintainer or release tool | Same, per release, often with migration steps | No. Missing notes are a weak signal at best. | Migration and highlights |
| Git commit log and diff | Contributors | The source tree at a tag | Partly. The tarball can differ from Git, as in xz. | Understanding intent and review history |
| Package diff (npm diff, Socket) | Computed from the published tarballs | The bytes that install | Yes. It is derived from the artifact itself. | Security review of an update |
| Registry metadata (time, integrity, provenance) | Registry and CI | When, by which credential or workflow | Partly. A hijacked pipeline still produces valid records. | Spotting off-process publishes |
Verdict: read the changelog to learn intent, review the package diff to learn behavior, and check registry metadata to confirm the release came through the usual path. Never let a changelog be the only evidence behind a security decision.
How do you verify a dependency update beyond the changelog?
#Verifying a dependency update beyond the changelog means comparing the published tarballs, not the repository, and checking how the release reached the registry. For one package, the manual version takes a few minutes.
- Check the publication path. Compare
npm view <pkg> timewith the project's Git tags and release page. A version with no tag and no notes is off-process until proven otherwise, as axios@1.14.1 was. - Diff the tarballs. Run
npm diff --diff=<pkg>@<old> --diff=<pkg>@<new> --diff-name-only, then read package.json and every new file in full. New dependencies, new lifecycle scripts, and new files outside the usual layout matter most. - Check capabilities. A minor release that suddenly reads environment variables, spawns a shell, or opens network connections deserves an explanation the changelog does not give.
- Verify signatures.
npm audit signatureschecks registry signatures and provenance attestations. It is necessary and, as TanStack showed, not sufficient.
Each step has a cost. Tarball diffs of bundled builds are long and mostly unreadable. Reproducible builds would bind a tarball to its source, but in results vlt published in February 2025, only 5.78% of 5,000 high-impact npm packages were reproducible from source, and only 3.72% had provenance attestations. A dependency cooldown buys time for detection to catch up, at the price of delaying real fixes.
Socket's package diffs exist to close this gap. Every package page on Socket can show the diff between two published versions, computed from the registry tarballs, like this one for debug 4.0.1. Socket for GitHub's Dependency Overview comment links that diff from pull requests that update a dependency, alongside the network, filesystem, shell, and environment access the new version uses. We wrote when we launched it that “there is no guarantee that code on GitHub matches code on npm.” Socket also raises alerts such as Install scripts, Obfuscated code, and New author on the new version itself. Socket flagged plain-crypto-js@4.2.1 within six minutes of its publication. None of this replaces reading a diff for code you care about. It makes the diff the default thing you look at, instead of the notes.
What registries and update tools should change
#The incentives point the wrong way today. Bots that open automated dependency updates commonly embed the upstream release notes in the pull request body, which puts the claim in front of the reviewer and leaves the evidence a click away. We think update bots should lead with the tarball diff summary: files added, dependencies added, scripts added, capabilities gained. Registries could flag a version published without a matching tag or release on the linked repository, a check that would have marked axios@1.14.1 the moment it appeared.
The deeper fix is binding. A changelog entry, a Git tag, and a tarball digest should be one signed statement, so a release that ships code its changelog does not describe is detectable by machines rather than by luck. Until that exists, the changelog remains the most-read and least-verified file in any release.
