release: produce a versioned per-browser artifact and pin the Chrome extension id (closes #310)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m44s
e2e / e2e-firefox (push) Successful in 32s

manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test.
This commit was merged in pull request #347.
This commit is contained in:
2026-08-23 16:13:22 +02:00
parent 669c443bf9
commit 769f6a5289
21 changed files with 2142 additions and 42 deletions

274
script/lib/zip.js Normal file
View File

@@ -0,0 +1,274 @@
// A minimal, deterministic ZIP writer and reader.
//
// Used by script/lib/package.js to build the distributable archives: a Chrome
// zip and a Firefox XPI are both ordinary zip files with manifest.json at the
// root, so one implementation covers both.
//
// Why this rather than a package or the zip(1) binary. A dependency would have
// to be hash-pinned like everything else in REPO_POLICIES.md, and this is
// about a hundred lines of stdlib zlib for a format the archives use two
// features of. The binary is worse: the release artifact would then depend on
// whichever Info-ZIP the machine happens to have, which is the same objection
// that keeps linting inside a container.
//
// Deterministic on purpose. Entries are sorted by name, every timestamp is the
// same fixed 1980-01-01 the format's epoch starts at, and the compression
// level is fixed, so building the same dist/ twice produces byte-identical
// archives and the sha256 in SHA256SUMS is a property of the input rather than
// of the clock. Two builds of the same commit that disagree are then visible
// instead of expected.
//
// Deliberately NOT implemented: zip64, encryption, data descriptors,
// directory entries (browsers infer directories from member paths), and
// anything to do with symlinks. writeZip refuses input it cannot represent
// rather than emitting an archive that is quietly wrong.
"use strict";
const zlib = require("zlib");
const LOCAL_SIG = 0x04034b50;
const CENTRAL_SIG = 0x02014b50;
const EOCD_SIG = 0x06054b50;
const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;
// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express.
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
const DOS_TIME = 0;
// Unix regular file, mode 0644, in the high 16 bits, which is where the "made
// by unix" convention puts it.
// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit.
const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0;
const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0
const VERSION_NEEDED = 20;
// Without zip64 every size and offset is a u32.
const MAX_U32 = 0xffffffff;
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c;
}
return table;
})();
// Written out rather than taken from zlib.crc32, which only exists from node
// 22.2: this runs from script/ on whatever node the host has as well as inside
// the pinned image, and a checksum that silently is not there is worse than
// twelve lines.
function crc32(buf) {
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
// Member names are stored as raw bytes. Anything outside ASCII would need the
// UTF-8 flag and interoperability care that nothing this repo emits requires,
// so it is refused instead of guessed at.
function encodeName(name) {
if (typeof name !== "string" || name === "") {
throw new Error(`zip: unusable member name ${JSON.stringify(name)}`);
}
const segments = name.split("/");
if (
name.startsWith("/") ||
name.includes("\\") ||
segments.some((s) => s === "" || s === "." || s === "..")
) {
throw new Error(
`zip: refusing member name ${JSON.stringify(name)}: archive ` +
`members must be relative paths under the archive root`,
);
}
if (/[^\x20-\x7e]/.test(name)) {
throw new Error(
`zip: refusing non-ASCII member name ${JSON.stringify(name)}`,
);
}
return Buffer.from(name, "ascii");
}
function compress(data) {
if (data.length === 0) {
return { method: METHOD_STORE, body: data };
}
const deflated = zlib.deflateRawSync(data, { level: 9 });
if (deflated.length >= data.length) {
return { method: METHOD_STORE, body: data };
}
return { method: METHOD_DEFLATE, body: deflated };
}
// entries: [{ name, data }]. Returns the archive as a Buffer.
function writeZip(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error("zip: refusing to write an archive with no members");
}
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
const seen = new Set();
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of sorted) {
const name = encodeName(entry.name);
if (seen.has(entry.name)) {
throw new Error(`zip: duplicate member ${entry.name}`);
}
seen.add(entry.name);
const data = Buffer.from(entry.data);
const { method, body } = compress(data);
if (data.length > MAX_U32 || body.length > MAX_U32) {
throw new Error(
`zip: ${entry.name} is too large for a non-zip64 archive`,
);
}
const crc = crc32(data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(LOCAL_SIG, 0);
local.writeUInt16LE(VERSION_NEEDED, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
name.copy(local, 30);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(CENTRAL_SIG, 0);
central.writeUInt16LE(VERSION_MADE_BY, 4);
central.writeUInt16LE(VERSION_NEEDED, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(DOS_TIME, 12);
central.writeUInt16LE(DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(EXTERNAL_ATTRS, 38);
if (offset > MAX_U32) {
throw new Error("zip: archive too large for a non-zip64 archive");
}
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
locals.push(local, body);
centrals.push(central);
offset += local.length + body.length;
}
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(EOCD_SIG, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(sorted.length, 8);
eocd.writeUInt16LE(sorted.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralBuf, eocd]);
}
// Read an archive back into [{ name, data }], from the central directory
// rather than by scanning for local headers: the central directory is the
// authoritative index, and a member reachable only by scanning is one a real
// unzipper would not extract.
//
// Every member's CRC is checked. The point of reading an archive back is to
// establish that it holds what it was meant to hold, so a member that does not
// decompress to its recorded checksum is a failure and never a warning.
function readZip(buf) {
if (buf.length < 22) {
throw new Error("zip: too short to be an archive");
}
// No archive this writes has a trailing comment, so the EOCD is the last
// 22 bytes. Anything else is not an archive this produced.
const eocdAt = buf.length - 22;
if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) {
throw new Error(
"zip: no end-of-central-directory record at the end of the " +
"archive (a trailing comment, or not a zip at all)",
);
}
const count = buf.readUInt16LE(eocdAt + 10);
const centralSize = buf.readUInt32LE(eocdAt + 12);
let at = buf.readUInt32LE(eocdAt + 16);
if (at + centralSize > eocdAt) {
throw new Error("zip: central directory runs past the archive");
}
const out = [];
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(at) !== CENTRAL_SIG) {
throw new Error(`zip: bad central directory entry ${i}`);
}
const method = buf.readUInt16LE(at + 10);
const crc = buf.readUInt32LE(at + 16);
const compSize = buf.readUInt32LE(at + 20);
const rawSize = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28);
const extraLen = buf.readUInt16LE(at + 30);
const commentLen = buf.readUInt16LE(at + 32);
const localAt = buf.readUInt32LE(at + 42);
const name = buf.toString("ascii", at + 46, at + 46 + nameLen);
at += 46 + nameLen + extraLen + commentLen;
if (buf.readUInt32LE(localAt) !== LOCAL_SIG) {
throw new Error(`zip: ${name} has no local header`);
}
// The local header's own name and extra lengths, not the central
// directory's: the two are allowed to differ and the data starts after
// the local ones.
const localNameLen = buf.readUInt16LE(localAt + 26);
const localExtraLen = buf.readUInt16LE(localAt + 28);
const dataAt = localAt + 30 + localNameLen + localExtraLen;
const body = buf.subarray(dataAt, dataAt + compSize);
let data;
if (method === METHOD_STORE) {
data = Buffer.from(body);
} else if (method === METHOD_DEFLATE) {
data = zlib.inflateRawSync(body);
} else {
throw new Error(`zip: ${name} uses compression method ${method}`);
}
if (data.length !== rawSize) {
throw new Error(
`zip: ${name} decompressed to ${data.length} bytes, not the ` +
`recorded ${rawSize}`,
);
}
if (crc32(data) !== crc) {
throw new Error(`zip: ${name} fails its recorded CRC32`);
}
out.push({ name, data });
}
return out;
}
module.exports = { crc32, readZip, writeZip };