Files
yaklang-chrome-extension/scripts/package-release.mjs
T
go0p 4507c2689d fix(ci): make release zips reproducible and bump version to 0.2.1
adm-zip stamps entries with file mtimes, i.e. the git checkout time on
CI, so two builds of the same commit differed byte-wise and tripped the
immutable no-overwrite guard on workflow re-runs. Pin every entry to the
commit date (SOURCE_DATE_EPOCH convention) instead.

0.2.0 was never published (no manifest entry, no GitHub release), but a
stale chrome-store zip from the first failed run occupies its immutable
slot; move the release to 0.2.1 rather than deleting the object.
2026-08-18 14:52:48 +08:00

129 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Packages the release variants from .output into dist/<version>/ and writes
* dist/release-entry.json recording filename/size/sha256/url for every
* artifact, plus per-artifact .sha256.txt checksum files.
*
* The variant table must stay in sync with `verify:production` (package.json)
* and scripts/audit-build.mjs — those define the published surface.
*
* Usage:
* node scripts/package-release.mjs --public-base-url=https://aliyun-oss.yaklang.com/chrome-extension [--dist=dist]
*/
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { access } from 'node:fs/promises';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
import AdmZip from 'adm-zip';
const execFileAsync = promisify(execFile);
const root = resolve(import.meta.dirname, '..');
const VARIANTS = [
{ variant: 'chrome-store', browser: 'chrome', mode: 'store', dir: '.output/chrome-mv3-store' },
{ variant: 'chrome-enterprise', browser: 'chrome', mode: 'enterprise', dir: '.output/chrome-mv3-enterprise' },
{ variant: 'firefox', browser: 'firefox', mode: 'production', dir: '.output/firefox-mv2' },
{ variant: 'firefox-amo', browser: 'firefox', mode: 'store', dir: '.output/firefox-mv3-store' },
];
function parseArgs(argv) {
const out = {};
for (const arg of argv) {
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
const eq = arg.indexOf('=');
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
out[key] = eq === -1 ? true : arg.slice(eq + 1);
}
return out;
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function sha256File(path) {
const hash = createHash('sha256');
await pipeline(createReadStream(path), hash);
return hash.digest('hex');
}
const args = parseArgs(process.argv.slice(2));
if (!args['public-base-url']) {
throw new Error('--public-base-url is required (e.g. https://aliyun-oss.yaklang.com/chrome-extension)');
}
const baseUrl = String(args['public-base-url']).replace(/\/+$/, '');
const distDir = resolve(root, String(args.dist ?? 'dist'));
const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
const { version } = pkg;
let commit = null;
let commitTime = null;
try {
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: root });
commit = stdout.trim();
const { stdout: iso } = await execFileAsync('git', ['show', '-s', '--format=%cI', 'HEAD'], { cwd: root });
commitTime = new Date(iso.trim());
} catch {
// Not fatal: local runs outside a git worktree still package fine.
}
// adm-zip stamps every entry with the file mtime, which is the checkout time
// on CI — two builds of the same commit would differ byte-wise and trip the
// immutable no-overwrite guard on re-runs. Pin all entries to the commit
// date (SOURCE_DATE_EPOCH convention) so artifacts are reproducible.
const sourceDateEpoch = Number(process.env.SOURCE_DATE_EPOCH)
|| (commitTime && !Number.isNaN(commitTime.getTime()) ? commitTime.getTime() : 0);
const versionDir = resolve(distDir, version);
await mkdir(versionDir, { recursive: true });
const artifacts = [];
for (const target of VARIANTS) {
const outputDir = resolve(root, target.dir);
if (!(await exists(resolve(outputDir, 'manifest.json')))) {
throw new Error(`${target.variant}: ${target.dir}/manifest.json missing — run the build first (pnpm verify:production)`);
}
const builtManifest = JSON.parse(await readFile(resolve(outputDir, 'manifest.json'), 'utf8'));
if (builtManifest.version !== version) {
throw new Error(`${target.variant}: built manifest version ${builtManifest.version} != package.json version ${version}`);
}
const filename = `${target.variant}-${version}.zip`;
const zipPath = resolve(versionDir, filename);
// Entry paths are relative to the output dir so manifest.json sits at the
// zip root, which is what browsers expect from a sideloaded extension.
const zip = new AdmZip();
zip.addLocalFolder(outputDir);
const pinned = new Date(Math.floor(sourceDateEpoch / 2000) * 2000); // DOS time has 2s granularity
for (const entry of zip.getEntries()) entry.header.time = pinned;
await zip.writeZipPromise(zipPath);
const sha256 = await sha256File(zipPath);
const size = (await stat(zipPath)).size;
await writeFile(resolve(versionDir, `${filename}.sha256.txt`), `${sha256} ${filename}\n`);
artifacts.push({
variant: target.variant,
browser: target.browser,
mode: target.mode,
filename,
url: `${baseUrl}/${version}/${filename}`,
sha256,
size,
checksum_url: `${baseUrl}/${version}/${filename}.sha256.txt`,
});
console.log(`packaged ${filename} (${size} bytes, sha256 ${sha256.slice(0, 12)}…)`);
}
const entry = { version, commit, built_at: new Date().toISOString(), artifacts };
await writeFile(resolve(distDir, 'release-entry.json'), `${JSON.stringify(entry, null, 2)}\n`);
console.log(`release entry written: ${resolve(distDir, 'release-entry.json').slice(root.length + 1)} (version ${version})`);