Versioning and Changelogs for Extension Releases

Produce store-legal MV3 version numbers from semver tags, handle the 65535 component ceiling, keep manifest and package versions in step, and generate release notes users can read.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

Extension stores accept a narrower version format than semver and consume each number permanently on upload. A 1.2.0-rc.1 tag is rejected outright, a date-derived 20260725 overflows the per-component ceiling, and a re-upload after a failed publish fails because that number is gone. This page is part of CI & Release Automation and covers a versioning scheme that survives contact with all three stores.

Root cause: store versions are not semver

A store version is one to four dot-separated integers, each between 0 and 65535, with no prefix, no suffix and no leading zeros. It must be strictly greater than the last version uploaded, and once uploaded it can never be used again — not after a rejection, not after a withdrawn draft. Semver’s pre-release and build metadata have nowhere to go in that format, so a mapping is required rather than optional.

Which version strings a store will acceptCommon versioning schemes checked against the store format rules, with the reason each is accepted or rejected.Version stringAccepted?Why1.4.2YesThree integers in range1.4.2.7YesFour components allowed1.4.2-rc.1NoSuffix not permittedv1.4.2NoPrefix not permitted20260725NoExceeds 655351.04.2NoLeading zero
Only the first two rows upload as written — everything else needs a mapping before it reaches the manifest.

Step 1. Map the tag to a store version deterministically

One function, used by the build and by nothing else, turns whatever your repository tags look like into a legal version. Making it pure and testable is what stops release-day improvisation.

 1// scripts/store-version.mjs
 2export function storeVersion(tag) {
 3  const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(?:rc|beta|alpha)\.(\d+))?$/i.exec(tag);
 4  if (!match) throw new Error(`tag is not convertible to a store version: ${tag}`);
 5
 6  const parts = [match[1], match[2], match[3], match[4]]
 7    .filter((p) => p !== undefined)
 8    .map((p) => {
 9      const n = Number(p);
10      if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`component out of range: ${p}`);
11      return n;
12    });
13
14  return parts.join(".");
15}

Execution context: Node, called from the build script before the manifest is written. Mapping a pre-release counter onto the fourth component keeps release candidates uploadable while leaving the three-component form for the real release — v1.4.2-rc.1 becomes 1.4.2.1, and the eventual v1.4.2 becomes 1.4.2, which is correctly lower than the candidate. That last point is the one to check against your own store history: if you upload candidates, the release itself must use a higher number, so promote candidates as 1.4.3 rather than reusing 1.4.2.

Step 2. Keep one source of truth

Two version numbers maintained by hand will diverge, usually on the release where it matters. Keep the number in package.json, generate the manifest, and make a stale value fail the build rather than ship.

1// scripts/build.mjs — the relevant slice
2const pkg = JSON.parse(await readFile("package.json", "utf8"));
3const version = process.env.RELEASE_TAG ? storeVersion(process.env.RELEASE_TAG) : pkg.version;
4
5const manifest = { ...base, ...patch, version };
6
7// The base manifest ships a placeholder, so a failed substitution can never reach a store.
8if (manifest.version === "0.0.0") throw new Error("version substitution did not happen");
9if (!/^\d+(\.\d+){0,3}$/.test(manifest.version)) throw new Error(`illegal store version: ${manifest.version}`);

Execution context: Node on the build machine or a CI runner. Validating the shape immediately after substitution catches the whole class of problems — an unmapped tag, an empty environment variable, a hand-edited manifest — at the cheapest possible moment. The placeholder in the base manifest is what makes the first check meaningful; without it, a missing substitution silently ships whatever was committed.

One number, three destinationsThe tag is mapped once, validated, and written into the manifest, the release notes and the store upload without being retyped.Git tagv1.4.2storeVersion()pure, testedValidatedshape + rangewritten everywhere from the same valuemanifest.jsonthe shipped artifactRelease notesheading and linksStore listingupload metadata
Every place the version appears is derived from the tag, so there is nothing to keep in step by hand.

Step 3. Generate notes users can actually read

Store release notes are read by users deciding whether to accept an update and by reviewers deciding whether the change matches the description. Commit subjects are the raw material, but a raw log is neither.

 1// scripts/changelog.mjs — group conventional commits into user-facing sections
 2import { execFileSync } from "node:child_process";
 3
 4const SECTIONS = [
 5  { key: "feat", title: "New" },
 6  { key: "fix", title: "Fixed" },
 7  { key: "perf", title: "Faster" },
 8];
 9
10export function notesSince(previousTag, tag) {
11  const range = previousTag ? `${previousTag}..${tag}` : tag;
12  const log = execFileSync("git", ["log", range, "--pretty=%s"], { encoding: "utf8" });
13  const lines = log.split("\n").filter(Boolean);
14
15  const out = [];
16  for (const { key, title } of SECTIONS) {
17    const matching = lines
18      .filter((line) => line.startsWith(`${key}:`) || line.startsWith(`${key}(`))
19      .map((line) => line.replace(/^[a-z]+(\([^)]*\))?:\s*/i, ""))
20      .map((line) => `- ${line.charAt(0).toUpperCase()}${line.slice(1)}`);
21    if (matching.length) out.push(`${title}\n${matching.join("\n")}`);
22  }
23
24  // Anything uncategorised is deliberately omitted from user-facing notes, not dumped in.
25  return out.join("\n\n") || "Maintenance and reliability improvements.";
26}

Execution context: Node on the CI runner, with the repository checked out at full depth so the range resolves. Dropping uncategorised commits rather than listing them is the editorial decision that makes these notes readable — a refactor or a dependency bump is not news to a user, and including it dilutes the two lines that are. The fallback string matters too: a release with no user-facing changes still needs notes, and an empty field reads as an oversight.

Step 4. Plan for the number you cannot reuse

Because a version is consumed on upload, a failed release costs a number. Design the scheme so that costs nothing: leave room in the patch component and never encode meaning that makes a skipped number confusing.

1// If an upload fails after consuming 1.4.2, the next attempt is simply 1.4.3.
2// Nothing in the code, the notes or the tag scheme assumes patch numbers are contiguous.
3export function nextPatch(version) {
4  const parts = version.split(".").map(Number);
5  parts[2] = (parts[2] ?? 0) + 1;
6  return parts.slice(0, 3).join(".");
7}

Execution context: Node, used when re-cutting a release after a failed upload. The one thing to avoid is a scheme where the version encodes something external — a build number, a date, a sprint — because then a skipped number is a discrepancy someone has to explain rather than a non-event.

Step 5. Keep the notes and the manifest in one release step

The last place versions diverge is between what the store listing says and what the package contains. Writing both from the same object in one step removes the gap, and makes the release reviewable as a single diff.

The release sequence, and the one irreversible stepTag, map, build, generate notes and upload — with the upload marked as the point after which the version can never be reused.tag pushedlisting updatedMap the tagpure functionBuild + validateplaceholder assertionGenerate notesfrom the commit r…Uploadversion consumed …Publishseparate, deliberatesafe to re-run any number of timespast here, cut a new patch number
Everything before the upload is repeatable; everything after it is a new version number.
 1// scripts/release.mjs — one object, written to every destination
 2const tag = process.env.RELEASE_TAG;
 3const previous = process.env.PREVIOUS_TAG || null;
 4
 5const release = {
 6  version: storeVersion(tag),
 7  notes: notesSince(previous, tag),
 8  tag,
 9};
10
11await writeFile("dist/release.json", JSON.stringify(release, null, 2));
12console.log(`prepared ${release.version} from ${tag}`);

Execution context: Node on the CI runner, before the upload step. Emitting a single release.json gives the upload script one input rather than three environment variables, and it leaves an artifact you can inspect after the fact when a listing does not say what you expected. It is also the natural place to add a dry-run flag: printing the object and stopping is enough to review a release without consuming anything.

Cross-browser variation

  • Chrome Web Store: one to four integers, each 0–65535, strictly increasing, consumed on upload. Release notes come from the listing rather than from the package.
  • Firefox AMO: accepts a wider format including some suffixes, but keeping to the Chrome-legal subset lets one number serve both. AMO also signs the package, so a rejected upload does not consume the version in the same way.
  • App Store (Safari): uses the app’s own versioning with a separate build number, so the extension version and the app version diverge. Map from the same tag to keep them traceable.
  • All stores: the version in the shipped manifest is what users see in their extensions list, so it should match the tag people quote in issues.

Verification

  1. Run the mapper over v1.4.2, v1.4.2-rc.3, 1.4.2, v1.4 and 20260725. The first three should convert, the last two should throw with a clear message.
  2. Build with a stale package.json version and confirm the placeholder assertion fires.
  3. Generate notes for a range containing a feature, a fix and a refactor. The refactor should not appear.
  4. Upload a version, then attempt the same version again. The failure should be immediate and unambiguous — that is the behaviour the scheme is designed around.

FAQ

Can I use semver pre-release tags directly?

No. Suffixes are rejected by the store format. Map the pre-release counter onto a fourth component, and remember that the eventual release must be a higher number than the candidates that preceded it.

Why is a date-based version rejected?

Because each component is capped at 65535 and a full date exceeds it. A 2026.7.25 form fits the rules, but it makes the ordering rules awkward at year boundaries and tells users nothing they cannot get from the listing date.

What happens if a release fails after upload?

The version is consumed. Cut the next patch number and move on — which is only painless if nothing in your scheme assumes contiguous numbering.

Should the manifest version match the git tag exactly?

Match it as closely as the format allows. Users quote the version from their extensions list, and a number that cannot be found in your tags turns every bug report into a lookup.

Other Testing, Debugging & Performance Optimization Resources