openreader/scripts/build-changelog-feed.mjs
Richard R 92762fe858 ci(docs): separate release-triggered deploy and enhance changelog feed sync
Extract release-based deploy triggers to new workflow for clarity and
reliability. Update docs-deploy.yml to streamline dispatch inputs and
remove release/workflow_run triggers. Extend build-changelog-feed.mjs to
support incremental changelog sync via workflow_dispatch with release tag
and action, enabling precise upserts and deletions of changelog entries.
2026-05-14 17:14:43 -06:00

264 lines
9 KiB
JavaScript

#!/usr/bin/env node
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const REPO = process.env.CHANGELOG_REPO || process.env.GITHUB_REPOSITORY || 'richardr1126/openreader';
const PUBLIC_BASE = (process.env.CHANGELOG_PUBLIC_BASE_URL || 'https://docs.openreader.richardr.dev').replace(/\/$/, '');
const OUTPUT_DIR = path.resolve('docs-site/static/changelog');
const RELEASES_DIR = path.join(OUTPUT_DIR, 'releases');
const MUTABLE_COUNT = Number(process.env.CHANGELOG_MUTABLE_COUNT || '3');
const FULL_MODE = process.argv.includes('--full') || process.env.CHANGELOG_FORCE_FULL === '1';
function normalizeTagSlug(tagName) {
const normalized = String(tagName || '').trim().toLowerCase();
const collapsed = normalized
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return collapsed || 'release';
}
function sortEntries(entries) {
return [...entries].sort((a, b) => {
const aMs = Date.parse(a.published_at);
const bMs = Date.parse(b.published_at);
if (Number.isFinite(aMs) && Number.isFinite(bMs) && aMs !== bMs) {
return bMs - aMs;
}
return String(b.tag_name || '').localeCompare(String(a.tag_name || ''));
});
}
function toManifestEntry(release) {
return {
tag_name: String(release.tag_name || ''),
name: String(release.name || release.tag_name || ''),
published_at: String(release.published_at || release.created_at || new Date().toISOString()),
html_url: String(release.html_url || ''),
prerelease: Boolean(release.prerelease),
body_path: '',
};
}
function toBodyRecord(release) {
return {
tag_name: String(release.tag_name || ''),
name: String(release.name || release.tag_name || ''),
published_at: String(release.published_at || release.created_at || new Date().toISOString()),
html_url: String(release.html_url || ''),
prerelease: Boolean(release.prerelease),
body: String(release.body || ''),
};
}
function applyBodyPath(entries) {
const seen = new Map();
return entries.map((entry) => {
const base = normalizeTagSlug(entry.tag_name);
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
const slug = count === 0 ? base : `${base}-${count + 1}`;
return { ...entry, body_path: `changelog/releases/${slug}.json` };
});
}
async function fetchJson(url, { auth = false } = {}) {
const headers = { Accept: 'application/vnd.github+json' };
if (auth && process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`Fetch failed ${res.status} for ${url}`);
}
return res.json();
}
async function fetchGitHubReleases() {
const out = [];
for (let page = 1; page <= 20; page += 1) {
const url = `https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`;
const data = await fetchJson(url, { auth: true });
if (!Array.isArray(data) || data.length === 0) break;
out.push(...data);
if (data.length < 100) break;
}
return out;
}
async function fetchGitHubReleaseByTag(tagName) {
const url = `https://api.github.com/repos/${REPO}/releases/tags/${encodeURIComponent(tagName)}`;
try {
return await fetchJson(url, { auth: true });
} catch {
return null;
}
}
async function readEventPayload() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath) return null;
try {
const raw = await readFile(eventPath, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function fetchRemoteManifest() {
const url = `${PUBLIC_BASE}/changelog/manifest.json`;
try {
const data = await fetchJson(url);
if (!Array.isArray(data?.releases)) return null;
return data;
} catch {
return null;
}
}
async function fetchRemoteBody(bodyPath) {
const url = `${PUBLIC_BASE}/${bodyPath.replace(/^\/+/, '')}`;
try {
return await fetchJson(url);
} catch {
return null;
}
}
async function loadRemoteState() {
const manifestDoc = await fetchRemoteManifest();
if (!manifestDoc) return null;
const manifest = Array.isArray(manifestDoc.releases) ? manifestDoc.releases : [];
const bodies = new Map();
await Promise.all(manifest.map(async (entry) => {
if (!entry?.body_path) return;
const bodyDoc = await fetchRemoteBody(entry.body_path);
if (bodyDoc && typeof bodyDoc.body === 'string') {
bodies.set(entry.tag_name, bodyDoc);
}
}));
return { manifest, bodies };
}
function isMutable(manifest, tagName) {
const idx = manifest.findIndex((x) => x.tag_name === tagName);
return idx >= 0 && idx < MUTABLE_COUNT;
}
async function buildState() {
const eventName = process.env.GITHUB_EVENT_NAME || '';
const payload = await readEventPayload();
const releaseEvent = payload?.release;
const releaseAction = payload?.action || '';
const remoteState = await loadRemoteState();
const shouldFull = FULL_MODE || !remoteState;
if (shouldFull) {
const releases = await fetchGitHubReleases();
const filtered = releases.filter((r) => !r.draft);
const entries = sortEntries(filtered.map(toManifestEntry));
const entriesWithPath = applyBodyPath(entries);
const bodies = new Map(filtered.map((r) => [r.tag_name, toBodyRecord(r)]));
return { entries: entriesWithPath, bodies, mode: 'full' };
}
const manifest = sortEntries(remoteState.manifest.filter((x) => !!x?.tag_name));
const bodies = new Map(remoteState.bodies);
if (eventName === 'release' && releaseEvent?.tag_name) {
const tagName = String(releaseEvent.tag_name);
const isDraft = Boolean(releaseEvent.draft);
const mutable = isMutable(manifest, tagName);
if (releaseAction === 'deleted') {
if (mutable) {
const next = manifest.filter((entry) => entry.tag_name !== tagName);
bodies.delete(tagName);
return { entries: applyBodyPath(sortEntries(next)), bodies, mode: 'incremental-delete' };
}
return { entries: applyBodyPath(manifest), bodies, mode: 'incremental-delete-skipped' };
}
if (!isDraft) {
const incoming = toManifestEntry(releaseEvent);
const existingIdx = manifest.findIndex((entry) => entry.tag_name === tagName);
if (existingIdx === -1 || mutable || existingIdx < MUTABLE_COUNT) {
if (existingIdx >= 0) manifest.splice(existingIdx, 1);
manifest.push(incoming);
bodies.set(tagName, toBodyRecord(releaseEvent));
}
}
return { entries: applyBodyPath(sortEntries(manifest)), bodies, mode: 'incremental-upsert' };
}
const dispatchInputs = payload?.inputs;
const dispatchTag = String(dispatchInputs?.release_tag || '').trim();
const dispatchAction = String(dispatchInputs?.release_action || '').trim().toLowerCase();
if (eventName === 'workflow_dispatch' && dispatchTag) {
if (dispatchAction === 'deleted') {
const mutable = isMutable(manifest, dispatchTag);
if (mutable) {
const next = manifest.filter((entry) => entry.tag_name !== dispatchTag);
bodies.delete(dispatchTag);
return { entries: applyBodyPath(sortEntries(next)), bodies, mode: 'incremental-delete' };
}
return { entries: applyBodyPath(manifest), bodies, mode: 'incremental-delete-skipped' };
}
const release = await fetchGitHubReleaseByTag(dispatchTag);
if (release && !release.draft) {
const incoming = toManifestEntry(release);
const existingIdx = manifest.findIndex((entry) => entry.tag_name === dispatchTag);
const mutable = isMutable(manifest, dispatchTag);
if (existingIdx === -1 || mutable || existingIdx < MUTABLE_COUNT) {
if (existingIdx >= 0) manifest.splice(existingIdx, 1);
manifest.push(incoming);
bodies.set(dispatchTag, toBodyRecord(release));
}
}
return { entries: applyBodyPath(sortEntries(manifest)), bodies, mode: 'incremental-upsert' };
}
return { entries: applyBodyPath(manifest), bodies, mode: 'mirror' };
}
async function writeState({ entries, bodies, mode }) {
await rm(OUTPUT_DIR, { recursive: true, force: true });
await mkdir(RELEASES_DIR, { recursive: true });
const manifest = {
generated_at: new Date().toISOString(),
source: `https://github.com/${REPO}/releases`,
mutable_window: MUTABLE_COUNT,
mode,
releases: entries,
};
for (const entry of entries) {
let body = bodies.get(entry.tag_name);
if (!body) {
body = {
tag_name: entry.tag_name,
name: entry.name,
published_at: entry.published_at,
html_url: entry.html_url,
prerelease: entry.prerelease,
body: '',
};
}
const outPath = path.resolve('docs-site/static', entry.body_path);
await mkdir(path.dirname(outPath), { recursive: true });
await writeFile(outPath, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
}
await writeFile(path.join(OUTPUT_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
console.log(`Wrote ${entries.length} changelog releases (${mode})`);
}
const state = await buildState();
await writeState(state);