mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
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.
129 lines
5.2 KiB
JavaScript
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})`);
|