Files
go0p 8af9bb777e
CI / Test and build (push) Canceled after 0s
fix(ci): keep updated_at stable when a re-publish changes nothing
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.
2026-08-18 15:07:48 +08:00

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' : ''})`);