Building Reproducible Release ZIPs
Produce byte-identical extension packages from the same source: pinned toolchains and lockfiles, deterministic bundler output, normalised file order and timestamps in the ZIP, excluding stray files, and verifying builds with checksums.
Table of Contents
Firefox’s add-on reviewers ask for your source code and build instructions, run them, and compare the result with the package you uploaded. If their build differs — a different file order in the ZIP, a timestamp, a newer transitive dependency, a stray .DS_Store — they cannot confirm the package matches the source, and review stalls. Even without a reviewer, a release that cannot be rebuilt identically is hard to audit, hard to debug, and impossible to prove untampered. Reproducible builds mean the same commit always produces the same bytes. For extensions, that takes a pinned toolchain, a deterministic bundler configuration and a carefully built ZIP. This guide covers all three. It belongs to CI and release automation.
Where non-determinism creeps in
An extension build has three stages, and each can vary. Dependencies: a ^ range or a missing lockfile pulls a newer version next week; a different Node or npm version resolves differently. Bundling: chunk names that include build time, absolute paths embedded in source maps, environment-dependent code (process.env.NODE_ENV unset on one machine), and non-deterministic module ordering. Packaging: ZIP tools record each file’s modification time and permissions, and add files in directory-listing order, which differs between file systems; OS metadata files get swept in. Fixing all three produces identical archives from any clean checkout.
Step-by-step: a reproducible package
1. Pin the toolchain
1// package.json
2{
3 "packageManager": "npm@10.8.2",
4 "engines": { "node": "20.17.0" },
5 "scripts": { "build": "vite build", "package": "node scripts/zip.mjs" }
6}
1# .nvmrc
220.17.0
Execution context: the repository. An exact Node version in .nvmrc and engines, plus packageManager (honoured by Corepack), means every machine and CI runner uses the same tools. Exact versions matter — minor Node releases have changed bundler output through different default behaviour.
2. Install exactly from the lockfile
1corepack enable
2npm ci --ignore-scripts
3npm run build
Execution context: CI and the reviewer’s machine. npm ci installs exactly what package-lock.json records and fails if it is out of date, unlike npm install. --ignore-scripts prevents dependency install scripts from running, which removes a source of variation and a supply-chain risk; run any scripts you genuinely need explicitly. See auditing third-party dependencies in an extension.
3. Make the bundler deterministic
1// vite.config.ts
2export default defineConfig(({ mode }) => ({
3 define: {
4 "process.env.NODE_ENV": JSON.stringify(mode === "production" ? "production" : "development"),
5 __BUILD_DATE__: JSON.stringify(new Date(Number(process.env.SOURCE_DATE_EPOCH ?? 0) * 1000).toISOString()),
6 },
7 build: {
8 sourcemap: "hidden",
9 minify: "esbuild",
10 rollupOptions: { output: { entryFileNames: "[name].js", chunkFileNames: "chunks/[name]-[hash].js", assetFileNames: "assets/[name]-[hash][extname]" } },
11 },
12}));
Execution context: the build configuration. Content hashes (not timestamps) in file names are stable for identical content. Any build date comes from SOURCE_DATE_EPOCH — set it to the commit time in CI (git log -1 --format=%ct) so rebuilding the same commit gives the same value. Hidden source maps are generated but not referenced from the bundle; keep them out of the package and upload them separately, as in uploading source maps for readable stack traces.
4. Build the ZIP deterministically
1// scripts/zip.mjs
2import { createWriteStream } from "node:fs";
3import { readdir, readFile, stat } from "node:fs/promises";
4import { join, relative, sep } from "node:path";
5import yazl from "yazl";
6
7const ROOT = "dist";
8const EXCLUDE = [/\.map$/, /(^|\/)\.DS_Store$/, /(^|\/)Thumbs\.db$/];
9const MTIME = new Date(Number(process.env.SOURCE_DATE_EPOCH ?? 315532800) * 1000); // default: 1980-01-01
10
11async function* walk(dir) {
12 for (const e of await readdir(dir, { withFileTypes: true })) {
13 const p = join(dir, e.name);
14 if (e.isDirectory()) yield* walk(p); else yield p;
15 }
16}
17
18const files = [];
19for await (const f of walk(ROOT)) {
20 const rel = relative(ROOT, f).split(sep).join("/");
21 if (!EXCLUDE.some((re) => re.test(rel))) files.push(rel);
22}
23files.sort(); // stable order on every OS
24
25const zip = new yazl.ZipFile();
26for (const rel of files) {
27 zip.addBuffer(await readFile(join(ROOT, rel)), rel, { mtime: MTIME, mode: 0o100644, compress: true });
28}
29zip.outputStream.pipe(createWriteStream(`release/extension-${process.env.npm_package_version}.zip`));
30zip.end();
Execution context: Node at the end of the build. Sorting the file list removes file-system ordering differences; a fixed modification time and file mode remove metadata differences; addBuffer avoids reading OS-specific attributes. The manifest must be at the archive root, which relative(ROOT, …) guarantees. Excluding .map files and OS junk keeps the package to what the extension needs.
5. Publish a checksum and verify twice
1sha256sum release/extension-2.4.0.zip | tee release/extension-2.4.0.zip.sha256
2# CI: build twice in clean directories and compare
3diff <(sha256sum run1/release/*.zip | cut -d' ' -f1) <(sha256sum run2/release/*.zip | cut -d' ' -f1)
Execution context: CI. Building twice in the same pipeline from clean checkouts and comparing checksums catches non-determinism the moment it is introduced, instead of when a reviewer reports it. Publish the checksum alongside the GitHub release.
6. Document the build for reviewers
1BUILD.md
2Requirements: Linux or macOS, Node 20.17.0 (see .nvmrc), npm 10.8.2 via Corepack
3Steps:
4 corepack enable
5 npm ci --ignore-scripts
6 SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) npm run build
7 SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) npm run package
8Output: release/extension-<version>.zip
Execution context: the source archive submitted to AMO. Reviewers follow these steps literally; every command must work in a clean environment without network access beyond the package registry. See signing and publishing to AMO from CI.
7. Build per-browser packages from one source
When manifests differ per browser, generate each manifest deterministically (stable key order, no timestamps) and zip each target with the same script into separate files. See browser-specific settings for Firefox and Safari.
Common mistakes
npm installin CI. Resolves new versions; usenpm ci.zip -r dist. Records timestamps and OS file order.- Build dates from
Date.now(). Every build differs. - Source maps inside the package. Bigger package, and it exposes source unnecessarily.
- Undocumented build steps. Reviewers cannot reproduce.
Cross-browser variation
- Chrome / Edge: stores don’t require reproducibility, but checksums help audit and incident response.
- Firefox: AMO requires source and build instructions when code is minified or bundled; reproducibility speeds review.
- Safari: Xcode builds an app bundle; reproduce the web extension resources first, then archive with pinned Xcode — see automating Safari builds with xcodebuild.
Verification
- Build the same commit on two machines and compare SHA-256 checksums.
- Unzip and confirm no
.map,.DS_Storeor test files are present. - Confirm
manifest.jsonis at the archive root. - Run the double-build check in CI and confirm it passes.
FAQ
Does compression level affect reproducibility?
Yes — keep the same library and level everywhere. Pinning the ZIP library version in the lockfile handles this.
Can Chrome’s CRX format be reproducible?
The ZIP inside can be; the CRX signature depends on the signing key and is applied by the store.
What about Windows line endings?
Configure .gitattributes (* text=auto eol=lf) so checkouts on Windows produce the same bytes as on Linux.
Is deterministic output worth it for a small extension?
Yes. The cost is a one-time setup of a few files, and it removes a whole category of “works on my machine” release problems along with speeding up any review that asks for source.
Should the source archive for reviewers be reproducible too?
Yes. Generate it with git archive --format=zip HEAD, which is deterministic for a given commit and excludes untracked files automatically.
Related
- Building a GitHub Actions pipeline for extensions — where this runs.
- Signing and publishing to AMO from CI — source submission.
- Keeping the extension bundle small — what goes in the ZIP.
- CI and release automation — the parent topic.