Skip to content
academia.sh

Lesson 06 / 14

Semantic Versioning

The promise carried by the three parts of a version number, comparison and precedence rules, pre-release tags, the bounds of version ranges, and the definition of a breaking change.

Contents

The previous lesson said that a range like ^1.4.0 in the manifest denotes not a single version but a set. What draws the set’s boundaries is a contract that assigns meaning to the parts of a version number.

This contract is called semantic versioning. It is not a technical mechanism, it is a way of making a promise: the party raising the number tells the consumer what it did. The part that is a mechanism — comparison and range arithmetic — is built on top of the contract.

The Promise the Three Parts Make

A version number consists of three numbers separated by dots: major, minor, and patch. Which part gets raised when a release is prepared depends on the kind of change made:

Change Part raised Promise given to the consumer
Bug fix, internal improvement Patch Your code runs unchanged
Backward-compatible capability addition Minor Your code runs unchanged, and there is a new capability
An edit that changes existing behavior Major Your code may change; read the migration notes

The subject of the promise is the package’s public interface: the paths listed in the exports map and the names reachable from those paths. Renaming an inner file not in the map does not raise the major version — this is the counterpart of encapsulation. This is the package-level contract that the backward compatibility concept introduced in the Programming Fundamentals course turns into.

The contract itself enforces nothing. Raising the number correctly is the author’s responsibility; on the consumer side, the limit of the trust placed in the number is what the next section’s explanation of why ranges are vulnerable is about.

Comparison and Precedence

The precondition for range arithmetic is that two versions can be compared. Comparison cannot be done by text order: version 1.10.0 is greater than 1.9.0 but looks smaller as text. The same problem was described for system packages in the package manager lesson of the Introduction to Linux course; here the rule is narrower and entirely written down.

The rule is three steps: the three numbers are compared numerically left to right; on equality, a version carrying a pre-release tag is smaller than one without; if both sides are pre-release, the tag parts are compared one by one.

// file: version.mjs
// Splits a version string into the core triple and pre-release parts; build metadata is discarded.
export function parse(string) {
  const [unversioned] = string.split('+');
  const [core, preRelease = ''] = unversioned.split('-', 2);
  const [major, minor, patch] = core.split('.').map(Number);
  return {
    major,
    minor,
    patch,
    preRelease: preRelease === '' ? [] : preRelease.split('.'),
  };
}

function comparePart(a, b) {
  const aNumber = /^\d+$/.test(a);
  const bNumber = /^\d+$/.test(b);
  if (aNumber && bNumber) return Number(a) - Number(b);
  if (aNumber) return -1;          // numeric part is smaller than a lettered part
  if (bNumber) return 1;
  return a < b ? -1 : a > b ? 1 : 0;
}

export function compare(x, y) {
  const a = parse(x);
  const b = parse(y);
  for (const field of ['major', 'minor', 'patch']) {
    if (a[field] !== b[field]) return a[field] - b[field];
  }
  if (a.preRelease.length === 0 && b.preRelease.length > 0) return 1;
  if (a.preRelease.length > 0 && b.preRelease.length === 0) return -1;
  const length = Math.min(a.preRelease.length, b.preRelease.length);
  for (let i = 0; i < length; i += 1) {
    const result = comparePart(a.preRelease[i], b.preRelease[i]);
    if (result !== 0) return result;
  }
  return a.preRelease.length - b.preRelease.length;
}
// file: sort.mjs -- version.mjs is the file above
import { compare } from './version.mjs';

const versions = ['1.10.0', '1.9.0', '2.0.0-rc.2', '2.0.0-rc.10', '2.0.0', '2.0.0-alpha', '1.9.0+build.7'];
console.log(versions.sort(compare).join('  <  '));
$ node sort.mjs
1.9.0  <  1.9.0+build.7  <  1.10.0  <  2.0.0-alpha  <  2.0.0-rc.2  <  2.0.0-rc.10  <  2.0.0

Four observations in the output summarize the whole rule.

Version 1.10.0 came after 1.9.0 — the parts were compared as numbers.

Version 2.0.0-alpha came before 2.0.0. A version carrying a pre-release tag is smaller than an untagged version with the same core triple. The tag means “this version is not that version yet.”

Version 2.0.0-rc.2 came before 2.0.0-rc.10. The tag’s numeric parts are compared as numbers; if text order were used, the order would come out reversed.

1.9.0 and 1.9.0+build.7 came out equal in the comparison: everything after the plus sign is build metadata and does not enter precedence. Because the sort is stable, the two kept their original order in the list. Equality means the two archives are counted as the same version; this is why build metadata cannot be used to tell published versions apart.

Version Ranges

The range in the manifest specifies the accepted set of versions. The two most commonly used markers rely on two different levels of the contract: one asks the major version to stay fixed, the other asks the minor version to stay fixed.

// file: range.mjs -- version.mjs is the file from the previous section
import { compare, parse } from './version.mjs';

// Converts a '^' or '~' range into a [lower, upper) pair.
export function bounds(range) {
  const marker = range[0] === '^' || range[0] === '~' ? range[0] : '=';
  const base = marker === '=' ? range : range.slice(1);
  const { major, minor, patch } = parse(base);
  if (marker === '=') return [base, `${major}.${minor}.${patch + 1}`];
  if (marker === '~') return [base, `${major}.${minor + 1}.0`];
  if (major > 0) return [base, `${major + 1}.0.0`];
  if (minor > 0) return [base, `0.${minor + 1}.0`];
  return [base, `0.0.${patch + 1}`];
}

export function satisfies(version, range) {
  const [lower, upper] = bounds(range);
  const isPreRelease = parse(version).preRelease.length > 0;
  const basePreRelease = parse(lower).preRelease.length > 0;
  if (isPreRelease && !basePreRelease) return false;
  return compare(version, lower) >= 0 && compare(version, upper) < 0;
}
// file: trial.mjs -- range.mjs is the file above
import { bounds, satisfies } from './range.mjs';

for (const range of ['^1.4.0', '~1.4.0', '^0.4.2', '^0.0.3', '1.4.0']) {
  const [lower, upper] = bounds(range);
  console.log(`${range.padEnd(8)} => ${lower} <= v < ${upper}`);
}

console.log('');
const trials = [
  ['1.4.9', '^1.4.0'], ['1.9.0', '^1.4.0'], ['2.0.0', '^1.4.0'],
  ['1.5.0', '~1.4.0'], ['0.4.9', '^0.4.2'], ['0.5.0', '^0.4.2'],
  ['2.0.0-rc.1', '^1.4.0'],
];
for (const [version, range] of trials) {
  console.log(`${version.padEnd(11)} ${range.padEnd(8)} ${satisfies(version, range) ? 'satisfies' : 'fails'}`);
}
$ node trial.mjs
^1.4.0   => 1.4.0 <= v < 2.0.0
~1.4.0   => 1.4.0 <= v < 1.5.0
^0.4.2   => 0.4.2 <= v < 0.5.0
^0.0.3   => 0.0.3 <= v < 0.0.4
1.4.0    => 1.4.0 <= v < 1.4.1

1.4.9       ^1.4.0   satisfies
1.9.0       ^1.4.0   satisfies
2.0.0       ^1.4.0   fails
1.5.0       ~1.4.0   fails
0.4.9       ^0.4.2   satisfies
0.5.0       ^0.4.2   fails
2.0.0-rc.1  ^1.4.0   fails

The caret says “let the major version not change” and accepts minor upgrades. The tilde says “let the minor version not change either” and accepts only patch upgrades. Unmarked notation pins a single version.

The third and fourth lines show a special rule. Packages with a major version of zero are not considered stable; the contract is not yet in effect. This is why the caret shifts one digit to the right at major version zero and keeps the minor version fixed. At minor version zero it shifts one more digit. This shift keeps unstable packages from slipping into a project through silent upgrades.

The last line shows the rule about pre-releases: version 2.0.0-rc.1, even though it is numerically smaller than 2.0.0, does not fall inside range ^1.4.0. Pre-releases are not included in any range unless the range’s base is itself already a pre-release. Otherwise a stable range would pull in versions that count as unpublished.

The Limits of the Contract

The convenience the range markers give — picking up minor upgrades automatically — rests on an assumption: that the publisher keeps the minor-version promise. This assumption weakens in three places.

The definition of a breaking change is ambiguous. A consumer relying on undocumented behavior breaks when that behavior changes; the publisher, meanwhile, believes it did not change the public interface. If the boundary of documented behavior is not written down, disagreement is inevitable.

A bug fix can break things too. A patch release that fixes a wrong result breaks code written around that wrong result. The contract still counts this as a patch.

Human error exists. The wrong part can be raised; a breaking change can be published as a minor release.

For these three reasons, ranges are a convenience, not a guarantee. What gives reproducibility is not the range but the lock file introduced in the previous lesson. The range says “which upgrades I am prepared to accept”; the lock file records the exact state of the installed tree. The two work together: the upgrade decision is made as a deliberate action, the lock file is updated, and the tests are run.

The tool that makes the contract enforceable on the publisher’s side is the deprecation step: a capability slated for removal is first marked in a minor release with a warning, the replacement path is documented, and only then removed in the next major release. Skipping this step means the major version number announces the upgrade but does not make the migration possible.

Summary

  • The three parts of a version number carry a promise: patch and minor upgrades announce that the code runs unchanged, a major upgrade that it may change.
  • The subject of the promise is the package’s public interface; a change to an encapsulated inner file does not raise the major version.
  • Comparison is done numerically over the parts; a pre-release-tagged version is smaller than an untagged one, and build metadata does not enter precedence.
  • The caret keeps the major version fixed, the tilde keeps the minor version fixed; for packages with a major version of zero these bounds shift one digit to the right.
  • Ranges are a convenience, not a guarantee: the definition of a breaking change is ambiguous, and patch fixes can break things too. The lock file provides reproducibility.

Next Step

Up to now, a single package’s single range was examined. In a real project, dozens of packages request the same dependency with different ranges, and these requests do not always intersect. The next lesson shows what happens when they do not: duplicate versions in the tree, two separate instances of the same library, and the identity problems this duality produces.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close