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.
264 lines
9 KiB
JavaScript
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);
|