#!/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();