Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ signAddon({
});
```

## Reproducible builds

`web-ext build` produces byte-for-byte identical ZIP archives when run repeatedly against the same source tree. Entries are added in a stable alphabetical order and every entry's timestamp is fixed, so the archive's checksum only changes when the source content changes. This is useful when [addons.mozilla.org](https://addons.mozilla.org) reviewers ask for a build script that reproduces the exact uploaded artifact.

The fixed timestamp itself is arbitrary — what matters for reproducibility is that it doesn't depend on when the build runs. By default, every entry is stamped with `1980-01-01T00:00:00Z`, the earliest time the ZIP format can represent, picked because it makes it obvious the timestamp is synthetic rather than a real file mtime. To use a meaningful timestamp instead — for example, the commit time of the source tree — set the [`SOURCE_DATE_EPOCH`](https://reproducible-builds.org/docs/source-date-epoch/) environment variable to a Unix timestamp in seconds before invoking `web-ext build`:

```sh
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) web-ext build
```

## Should I Use It?

Yes! The web-ext tool enables you to build and ship extensions for Firefox.
Expand Down
59 changes: 23 additions & 36 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@
"tmp": "0.2.5",
"update-notifier": "7.3.1",
"watchpack": "2.5.1",
"yargs": "17.7.2",
"zip-dir": "2.0.0"
"yargs": "17.7.2"
},
"devDependencies": {
"@babel/cli": "7.28.6",
Expand Down
75 changes: 73 additions & 2 deletions src/cmd/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import fs from 'fs/promises';
import parseJSON from 'parse-json';
import stripBom from 'strip-bom';
import defaultFromEvent from 'promise-toolbox/fromEvent';
import zipDir from 'zip-dir';
import JSZip from 'jszip';

import defaultSourceWatcher from '../watcher.js';
import getValidatedManifest, { getManifestId } from '../util/manifest.js';
Expand All @@ -17,6 +17,77 @@ import { createFileFilter as defaultFileFilterCreator } from '../util/file-filte
const log = createLogger(import.meta.url);
const DEFAULT_FILENAME_TEMPLATE = '{name}-{version}.zip';

// 1980-01-01 UTC — the earliest timestamp representable in a ZIP entry.
const ZIP_EPOCH_SECONDS = 315532800;

// Build a ZIP buffer with deterministic byte output: entries are sorted
// alphabetically, and every entry's timestamp is fixed (overridable via the
// SOURCE_DATE_EPOCH environment variable, per the Reproducible Builds spec).
export async function createDeterministicZip(sourceDir, { filter } = {}) {
const epochSeconds = process.env.SOURCE_DATE_EPOCH
? Number(process.env.SOURCE_DATE_EPOCH)
: ZIP_EPOCH_SECONDS;
if (!Number.isFinite(epochSeconds)) {
throw new UsageError(
`Invalid SOURCE_DATE_EPOCH value: ${process.env.SOURCE_DATE_EPOCH}`,
);
}
const fixedDate = new Date(epochSeconds * 1000);

const resolvedRoot = path.resolve(sourceDir);
const entries = [];

async function walk(dir) {
const dirents = await fs.readdir(dir, { withFileTypes: true });
dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const dirent of dirents) {
const full = path.join(dir, dirent.name);
const stat = await fs.lstat(full);
if (filter && !filter(full, stat)) {
continue;
}
if (dirent.isDirectory()) {
entries.push({ full, isDir: true });
await walk(full);
} else if (dirent.isFile()) {
entries.push({ full, isDir: false });
}
}
}
await walk(resolvedRoot);

const zip = new JSZip();
for (const entry of entries) {
// ZIP entries always use forward slashes, regardless of host OS.
const relative = path
.relative(resolvedRoot, entry.full)
.split(path.sep)
.join('/');
// `createFolders: false` keeps JSZip from injecting parent-folder
// entries with a current-time `Date()`. The walk above already emits
// every directory explicitly, so we control all entry timestamps.
if (entry.isDir) {
zip.file(relative, null, {
dir: true,
date: fixedDate,
createFolders: false,
});
} else {
const data = await fs.readFile(entry.full);
zip.file(relative, data, { date: fixedDate, createFolders: false });
}
}

// platform: 'UNIX' fixes the external-attribute byte; without it JSZip
// derives it from process.platform, which would make the same source
// produce different bytes on Windows vs Linux.
return zip.generateAsync({
compression: 'DEFLATE',
type: 'nodebuffer',
platform: 'UNIX',
});
}

export function safeFileName(name) {
return name.toLowerCase().replace(/[^a-z0-9.-]+/g, '_');
}
Expand Down Expand Up @@ -131,7 +202,7 @@ export async function defaultPackageCreator(
manifestData = await getValidatedManifest(sourceDir);
}

const buffer = await zipDir(sourceDir, {
const buffer = await createDeterministicZip(sourceDir, {
filter: (...args) => fileFilter.wantFile(...args),
});

Expand Down
55 changes: 55 additions & 0 deletions tests/unit/test-cmd/test.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { it, describe } from 'mocha';
import { assert } from 'chai';
import * as sinon from 'sinon';
import * as promiseToolbox from 'promise-toolbox';
import JSZip from 'jszip';

import build, {
safeFileName,
Expand Down Expand Up @@ -58,6 +59,60 @@ describe('build', () => {
);
});

async function assertAllEntriesHaveDate(extensionPath, expectedMs) {
const zip = await JSZip.loadAsync(await fs.readFile(extensionPath));
const entryNames = Object.keys(zip.files);
assert.isAbove(
entryNames.length,
0,
'zip should contain at least one entry',
);
for (const name of entryNames) {
// ZIP DOS time encodes seconds in 5 bits (halved), so the on-disk
// resolution is 2 seconds. JSZip rounds when generating, so the
// round-tripped Date may be up to 2s off the value we passed in.
assert.closeTo(
zip.files[name].date.getTime(),
expectedMs,
2000,
`entry ${name} has unexpected timestamp ${zip.files[name].date.toISOString()}`,
);
}
}

it('stamps every zip entry with the default fixed timestamp', () =>
withTempDir(async (tmpDir) => {
const { extensionPath } = await build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
});
// Default is 1980-01-01T00:00:00Z, the earliest time the ZIP format
// can represent. The actual value doesn't matter — what matters is
// that it is fixed, not derived from "now".
await assertAllEntriesHaveDate(extensionPath, Date.UTC(1980, 0, 1));
}));

it('stamps every zip entry with SOURCE_DATE_EPOCH when set', async () => {
const epochSeconds = 1700000000;
const original = process.env.SOURCE_DATE_EPOCH;
process.env.SOURCE_DATE_EPOCH = String(epochSeconds);
try {
await withTempDir(async (tmpDir) => {
const { extensionPath } = await build({
sourceDir: fixturePath('minimal-web-ext'),
artifactsDir: tmpDir.path(),
});
await assertAllEntriesHaveDate(extensionPath, epochSeconds * 1000);
});
} finally {
if (original === undefined) {
delete process.env.SOURCE_DATE_EPOCH;
} else {
process.env.SOURCE_DATE_EPOCH = original;
}
}
});

it('configures a build command with the expected fileFilter', () => {
const packageCreator = sinon.spy(() => ({
extensionPath: 'extension/path',
Expand Down