mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-21 19:10:41 +08:00
CI / Test and build (push) Canceled after 0s
A no-op re-publish produced new manifest bytes and checksum for identical content, which raced the CDN cache and failed verification. Compare against the existing manifest verbatim and keep its updated_at when the merged result is unchanged.
132 lines
5.9 KiB
JavaScript
132 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Merges the freshly packaged release (dist/release-entry.json) into the
|
|
* public manifest and writes manifest.json + manifest.json.sha256.txt.
|
|
*
|
|
* The manifest is the single entry point consumers read: `latest` plus a
|
|
* bounded `versions[]` history. Artifact objects are immutable and their URLs
|
|
* are never rewritten; only this manifest moves.
|
|
*
|
|
* Usage:
|
|
* node scripts/build-manifest.mjs --release-entry=dist/release-entry.json \
|
|
* [--existing-manifest=dist/existing-manifest.json] [--max-versions=10] \
|
|
* --output=dist/manifest.json --checksum-output=dist/manifest.json.sha256.txt
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
|
|
const root = resolve(import.meta.dirname, '..');
|
|
|
|
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;
|
|
}
|
|
|
|
function artifactFingerprint(artifacts) {
|
|
return artifacts.map((a) => `${a.variant}:${a.sha256}`).sort().join('|');
|
|
}
|
|
|
|
function toVersionEntry(entry) {
|
|
return {
|
|
version: entry.version,
|
|
published_at: entry.built_at,
|
|
commit: entry.commit ?? null,
|
|
artifacts: entry.artifacts.map((a) => ({
|
|
variant: a.variant,
|
|
browser: a.browser,
|
|
mode: a.mode,
|
|
filename: a.filename,
|
|
url: a.url,
|
|
sha256: a.sha256,
|
|
size: a.size,
|
|
checksum_url: a.checksum_url,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function validate(manifest) {
|
|
if (!Array.isArray(manifest.versions) || manifest.versions.length === 0) {
|
|
throw new Error('manifest must contain at least one version');
|
|
}
|
|
if (manifest.latest !== manifest.versions[0].version) {
|
|
throw new Error(`manifest.latest (${manifest.latest}) must equal versions[0].version (${manifest.versions[0].version})`);
|
|
}
|
|
const seen = new Set();
|
|
for (const versionEntry of manifest.versions) {
|
|
if (seen.has(versionEntry.version)) throw new Error(`duplicate version in manifest: ${versionEntry.version}`);
|
|
seen.add(versionEntry.version);
|
|
if (!Array.isArray(versionEntry.artifacts) || versionEntry.artifacts.length === 0) {
|
|
throw new Error(`version ${versionEntry.version} has no artifacts`);
|
|
}
|
|
const variants = new Set();
|
|
for (const artifact of versionEntry.artifacts) {
|
|
if (variants.has(artifact.variant)) throw new Error(`duplicate variant ${artifact.variant} in version ${versionEntry.version}`);
|
|
variants.add(artifact.variant);
|
|
if (!/^[0-9a-f]{64}$/.test(artifact.sha256)) throw new Error(`artifact ${artifact.filename}: bad sha256`);
|
|
if (!Number.isInteger(artifact.size) || artifact.size <= 0) throw new Error(`artifact ${artifact.filename}: bad size`);
|
|
if (!/^https?:\/\//.test(artifact.url)) throw new Error(`artifact ${artifact.filename}: url must be absolute`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (!args['release-entry']) throw new Error('--release-entry is required');
|
|
if (!args.output) throw new Error('--output is required');
|
|
if (!args['checksum-output']) throw new Error('--checksum-output is required');
|
|
|
|
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
|
const maxVersions = Number.parseInt(String(args['max-versions'] ?? '10'), 10);
|
|
if (!Number.isInteger(maxVersions) || maxVersions < 1) throw new Error('--max-versions must be a positive integer');
|
|
|
|
let versions = [];
|
|
let existingUpdatedAt = null;
|
|
let existingManifestBytes = null;
|
|
if (args['existing-manifest']) {
|
|
try {
|
|
existingManifestBytes = await readFile(resolve(root, String(args['existing-manifest'])));
|
|
const existing = JSON.parse(existingManifestBytes.toString('utf8'));
|
|
versions = Array.isArray(existing.versions) ? existing.versions : [];
|
|
existingUpdatedAt = typeof existing.updated_at === 'string' ? existing.updated_at : null;
|
|
} catch (err) {
|
|
if (err?.code !== 'ENOENT') throw err;
|
|
console.log('existing manifest not found; starting a fresh history');
|
|
}
|
|
}
|
|
|
|
const newEntry = toVersionEntry(entry);
|
|
const idx = versions.findIndex((v) => v.version === entry.version);
|
|
if (idx >= 0 && artifactFingerprint(versions[idx].artifacts) === artifactFingerprint(entry.artifacts)) {
|
|
// Idempotent rerun: keep the original entry (published_at stays stable).
|
|
console.log(`version ${entry.version} already in manifest with identical artifacts; kept as-is`);
|
|
} else {
|
|
if (idx >= 0) {
|
|
versions.splice(idx, 1);
|
|
console.log(`version ${entry.version} re-published with different artifacts; replaced entry`);
|
|
}
|
|
versions.unshift(newEntry);
|
|
}
|
|
versions = versions.slice(0, maxVersions);
|
|
|
|
// Preserve the previous updated_at when nothing actually changed: a no-op
|
|
// re-publish would otherwise produce new manifest bytes (and a new checksum)
|
|
// for identical content, racing the CDN's cache window.
|
|
const candidate = { latest: versions[0].version, updated_at: '__now__', versions };
|
|
const rebuildWith = (updatedAt) => JSON.stringify({ ...candidate, updated_at: updatedAt }, null, 2);
|
|
const previousBytes = existingManifestBytes ? existingManifestBytes.toString('utf8').trimEnd() : null;
|
|
const unchanged = existingUpdatedAt !== null && previousBytes === rebuildWith(existingUpdatedAt);
|
|
const manifest = { ...candidate, updated_at: unchanged ? existingUpdatedAt : new Date().toISOString() };
|
|
validate(manifest);
|
|
|
|
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
await writeFile(resolve(root, String(args.output)), bytes);
|
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
await writeFile(resolve(root, String(args['checksum-output'])), `${sha256} manifest.json\n`);
|
|
console.log(`manifest written: ${args.output} (latest=${manifest.latest}, ${versions.length} version(s) retained${unchanged ? ', content unchanged' : ''})`);
|