Files
git.stella-ops.org/scripts/api-changelog.mjs
StellaOps Bot 150b3730ef
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
up
2025-11-24 07:52:25 +02:00

130 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import yaml from 'yaml';
const ROOT = path.resolve('src/Api/StellaOps.Api.OpenApi');
const BASELINE = path.join(ROOT, 'baselines', 'stella-baseline.yaml');
const CURRENT = path.join(ROOT, 'stella.yaml');
const OUTPUT = path.join(ROOT, 'CHANGELOG.md');
const RELEASE_OUT = path.resolve('src/Sdk/StellaOps.Sdk.Release/out/api-changelog');
function panic(message) {
console.error(`[api:changelog] ${message}`);
process.exit(1);
}
function loadSpec(file) {
if (!fs.existsSync(file)) {
panic(`Spec not found: ${file}`);
}
return yaml.parse(fs.readFileSync(file, 'utf8'));
}
function enumerateOps(spec) {
const ops = new Map();
for (const [route, methods] of Object.entries(spec.paths || {})) {
for (const [method, operation] of Object.entries(methods || {})) {
const lower = method.toLowerCase();
if (!['get','post','put','delete','patch','head','options','trace'].includes(lower)) continue;
const id = `${lower.toUpperCase()} ${route}`;
ops.set(id, operation || {});
}
}
return ops;
}
function diffSpecs(oldSpec, newSpec) {
const oldOps = enumerateOps(oldSpec);
const newOps = enumerateOps(newSpec);
const additive = [];
const breaking = [];
for (const id of newOps.keys()) {
if (!oldOps.has(id)) {
additive.push(id);
}
}
for (const id of oldOps.keys()) {
if (!newOps.has(id)) {
breaking.push(id);
}
}
return { additive: additive.sort(), breaking: breaking.sort() };
}
function renderMarkdown(diff) {
const lines = [];
lines.push('# API Changelog');
lines.push('');
const date = new Date().toISOString();
lines.push(`Generated: ${date}`);
lines.push('');
lines.push('## Additive Operations');
if (diff.additive.length === 0) {
lines.push('- None');
} else {
diff.additive.forEach((op) => lines.push(`- ${op}`));
}
lines.push('');
lines.push('## Breaking Operations');
if (diff.breaking.length === 0) {
lines.push('- None');
} else {
diff.breaking.forEach((op) => lines.push(`- ${op}`));
}
lines.push('');
return lines.join('\n');
}
function ensureReleaseDir() {
fs.mkdirSync(RELEASE_OUT, { recursive: true });
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function signDigest(digest) {
const key = process.env.API_CHANGELOG_SIGNING_KEY;
if (!key) {
return null;
}
const hmac = crypto.createHmac('sha256', Buffer.from(key, 'utf8'));
hmac.update(digest);
return hmac.digest('hex');
}
function main() {
if (!fs.existsSync(BASELINE)) {
console.log('[api:changelog] baseline missing; skipping');
return;
}
const diff = diffSpecs(loadSpec(BASELINE), loadSpec(CURRENT));
const markdown = renderMarkdown(diff);
fs.writeFileSync(OUTPUT, markdown, 'utf8');
console.log(`[api:changelog] wrote changelog to ${OUTPUT}`);
ensureReleaseDir();
const releaseChangelog = path.join(RELEASE_OUT, 'CHANGELOG.md');
fs.writeFileSync(releaseChangelog, markdown, 'utf8');
const digest = sha256(markdown);
const digestFile = path.join(RELEASE_OUT, 'CHANGELOG.sha256');
fs.writeFileSync(digestFile, `${digest} CHANGELOG.md\n`, 'utf8');
const signature = signDigest(digest);
if (signature) {
fs.writeFileSync(path.join(RELEASE_OUT, 'CHANGELOG.sig'), signature, 'utf8');
console.log('[api:changelog] wrote signature for release artifact');
} else {
console.log('[api:changelog] signature skipped (API_CHANGELOG_SIGNING_KEY not set)');
}
console.log(`[api:changelog] copied changelog + digest to ${RELEASE_OUT}`);
}
main();