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

294
script/lib/package.js Normal file
View File

@@ -0,0 +1,294 @@
// Turn a verified dist/ into the two distributable archives.
//
// Invoked by script/package, which runs `make build` first so that dist/ has
// already been checked against the build's own receipt (see the Build Receipts
// section of README.md). This program does not build anything and does not
// write into dist/: it reads the emitted tree and writes release/.
//
// release/autistmask-chrome-<version>.zip loaded via chrome://extensions
// release/autistmask-firefox-<version>.xpi an UNSIGNED add-on, see README
// release/SHA256SUMS
//
// Self-containment is checked rather than assumed, because the layout invites
// exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT,
// outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only
// by accident, and would stop being correct the moment a reference pointed up
// and out. So every path the manifest and the popup HTML reference is resolved
// and required to be inside the archive, a reference that escapes the browser
// directory is a hard failure, and anything sitting at the dist/ root is
// listed as deliberately not shipped rather than silently dropped.
//
// The archive is then read back and compared byte for byte against the
// directory it was built from. An archive nobody opened is a claim, not an
// artifact.
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version");
const ROOT = path.resolve(__dirname, "..", "..");
const DIST = path.join(ROOT, "dist");
const RELEASE = path.join(ROOT, "release");
const TARGETS = [
{ dir: "chrome", ext: "zip" },
// .xpi rather than .zip: it is the same container, but Firefox's install
// flow keys off the extension.
{ dir: "firefox", ext: "xpi" },
];
// Strings in a manifest that name a file the extension loads. Matched by
// shape, not by a list of manifest keys, so a key added in a later manifest
// version is covered the day it appears rather than the day someone remembers
// to extend a list here. Nothing else in either manifest looks like this: the
// CSP strings, "<all_urls>", the version and the base64 key all fail it.
const MANIFEST_PATH_RE =
/^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/;
// Local references out of an HTML document. Enough for what this repo emits —
// one stylesheet link and one script tag — and anything it does not understand
// is reported rather than passed over, see htmlReferences().
const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi;
function fail(message) {
throw new Error(message);
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
// Every regular file under dir, as archive-root-relative forward-slashed
// paths. A symlink is refused rather than followed: build.js emits regular
// files only, so a link under dist/ is not something the build produced, and
// dereferencing one would put bytes from outside dist/ into the artifact.
function listFiles(dir, prefix = "") {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
fail(
`${dir}/${entry.name} is a symlink. The build emits regular ` +
`files only, so this is not something it produced and it ` +
`will not be archived.`,
);
} else if (entry.isDirectory()) {
out.push(...listFiles(path.join(dir, entry.name), rel));
} else if (entry.isFile()) {
out.push(rel);
} else {
fail(
`${dir}/${entry.name} is neither a regular file nor a ` +
`directory, so it is not something the build emitted`,
);
}
}
return out.sort();
}
// Collect every string anywhere in the manifest that looks like a file it
// loads, plus every string that tries to reach outside the extension root.
// The second half is the point: "../styles.css" never matches
// MANIFEST_PATH_RE, so without an explicit check an escaping reference would
// read as "not a path" and the missing file would be found only by a user
// whose popup rendered unstyled.
function manifestReferences(value, found = new Set()) {
if (typeof value === "string") {
if (value.split("/").includes("..")) {
fail(
`the manifest references ${JSON.stringify(value)}, which ` +
`points outside the extension root. Everything the ` +
`browser loads has to be inside the archive; nothing ` +
`above it is shipped.`,
);
}
if (MANIFEST_PATH_RE.test(value)) found.add(value);
} else if (Array.isArray(value)) {
for (const v of value) manifestReferences(v, found);
} else if (value && typeof value === "object") {
for (const v of Object.values(value)) manifestReferences(v, found);
}
return found;
}
// Local references out of one HTML member, resolved against that member's own
// directory and returned archive-relative. Absolute URLs, data: URIs and
// in-page anchors are not files and are skipped; a relative reference that
// climbs out of the archive root is a failure for the same reason as above.
function htmlReferences(member, text) {
const base = path.posix.dirname(member);
const out = new Set();
for (const match of text.matchAll(HTML_REF_RE)) {
const ref = match[1].trim();
if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue;
if (ref.startsWith("/")) {
fail(
`${member} references ${JSON.stringify(ref)} from the ` +
`extension root. Nothing here emits root-absolute ` +
`references and this packager does not resolve them.`,
);
}
const resolved = path.posix.normalize(path.posix.join(base, ref));
if (resolved.startsWith("..")) {
fail(
`${member} references ${JSON.stringify(ref)}, which resolves ` +
`outside the extension root. build.js copies the ` +
`compiled stylesheet into each browser directory for ` +
`exactly this reason: dist/styles.css lives at the dist/ ` +
`root and is not part of either archive.`,
);
}
out.add(resolved);
}
return out;
}
// Everything the browser is told to load, and the assertion that all of it is
// in the archive.
function checkSelfContained(target, members, read) {
if (!members.includes("manifest.json")) {
fail(`dist/${target} has no manifest.json at its root`);
}
const manifest = JSON.parse(read("manifest.json").toString("utf8"));
const referenced = new Set(manifestReferences(manifest));
for (const member of members) {
if (!member.endsWith(".html")) continue;
for (const ref of htmlReferences(
member,
read(member).toString("utf8"),
)) {
referenced.add(ref);
}
}
const missing = [...referenced].filter((r) => !members.includes(r));
if (missing.length > 0) {
fail(
`the ${target} archive would not be self-contained: it is told ` +
`to load ${missing.join(", ")}, which ${
missing.length === 1 ? "is" : "are"
} not in it`,
);
}
return { manifest, referenced };
}
function main() {
const version = resolveVersion(ROOT);
if (!fs.existsSync(DIST)) {
fail(
"there is no dist/ to package. script/package runs make build " +
"first; run it rather than this program.",
);
}
fs.rmSync(RELEASE, { recursive: true, force: true });
fs.mkdirSync(RELEASE, { recursive: true });
// Files the build emits at the dist/ root, outside both browser
// directories. Printed rather than ignored: dist/styles.css is the
// Tailwind output that build.js then copies into each browser directory,
// so leaving it out is correct — but "correct and stated" and "dropped by
// a glob" are different things, and only one of them survives the next
// change to the build.
const rootOnly = fs
.readdirSync(DIST, { withFileTypes: true })
.filter((e) => !e.isDirectory())
.map((e) => e.name)
.sort();
if (rootOnly.length > 0) {
console.log(
`Not shipped (dist/ root, outside every browser directory, and ` +
`referenced by nothing inside one): ${rootOnly.join(", ")}`,
);
}
const sums = [];
for (const { dir, ext } of TARGETS) {
const targetDir = path.join(DIST, dir);
if (!fs.existsSync(targetDir)) {
fail(`dist/${dir} does not exist; run make build`);
}
const members = listFiles(targetDir);
const readFromDir = (member) =>
fs.readFileSync(path.join(targetDir, member));
const { manifest } = checkSelfContained(dir, members, readFromDir);
if (manifest.version !== version) {
fail(
`dist/${dir}/manifest.json says version ${manifest.version} ` +
`but this tree is ${version}. dist/ is stale: run make ` +
`build.`,
);
}
const archive = writeZip(
members.map((name) => ({ name, data: readFromDir(name) })),
);
const name = `autistmask-${dir}-${version}.${ext}`;
const outPath = path.join(RELEASE, name);
fs.writeFileSync(outPath, archive);
// Read the artifact back off disk, not the buffer that was just
// written: what ships is the file.
const written = fs.readFileSync(outPath);
const entries = readZip(written);
const inArchive = entries.map((e) => e.name).sort();
if (inArchive.join("\n") !== members.join("\n")) {
fail(
`${name} does not hold the same members as dist/${dir}: ` +
`archive has ${inArchive.length}, directory has ` +
`${members.length}`,
);
}
for (const entry of entries) {
const onDisk = readFromDir(entry.name);
if (sha256(entry.data) !== sha256(onDisk)) {
fail(`${name} member ${entry.name} differs from dist/${dir}`);
}
}
// Re-run the self-containment check against the ARCHIVE's own
// contents. The directory passing it is not the claim being made.
const byName = new Map(entries.map((e) => [e.name, e.data]));
checkSelfContained(dir, inArchive, (m) => byName.get(m));
const digest = sha256(written);
sums.push(`${digest} ${name}`);
console.log(
`${name}: ${entries.length} member(s), ${written.length} bytes, ` +
`sha256 ${digest}`,
);
}
fs.writeFileSync(
path.join(RELEASE, "SHA256SUMS"),
sums.map((l) => `${l}\n`).join(""),
);
console.log(`Wrote release/ for version ${version}`);
}
// Only when run as a program. The reference-resolving helpers are what decide
// whether an archive is self-contained, so tests/packaging.test.js exercises
// them directly and must be able to require this file without packaging
// anything.
if (require.main === module) {
try {
main();
} catch (err) {
console.error(`package: ${err && err.message ? err.message : err}`);
process.exit(1);
}
}
module.exports = { checkSelfContained, htmlReferences, manifestReferences };