diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 0000000..e85173a --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,96 @@ +name: Changelog preview + +# Dispatched on-demand by the SDK bot when an operator clicks "Generate staged". +# Renders the changelog (with the canonical sdk-release-metadata logic) scoped to +# the staged service set, then POSTs it back to the bot keyed by preview_id. The +# bot polls its changelog_previews table and shows the editable result. No PRs, +# no SDK checkout — pure analysis. + +on: + workflow_dispatch: + inputs: + services: + description: 'Comma-separated post-mount service names to scope the changelog to' + required: false + type: string + preview_id: + description: 'Id the bot keys the result on' + required: true + type: string + base_ref: + description: 'Spec ref to render the changelog for' + required: false + default: main + type: string + +concurrency: + # A re-dispatch of the same preview supersedes the prior one. + group: changelog-preview-${{ github.event.inputs.preview_id }} + cancel-in-progress: true + +env: + SDK_BOT_URL: https://sdk-automation-bot.workos.tools + +jobs: + preview: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build policy module + # render-changelog-preview.mjs reads mountRules from dist/policy.mjs to + # resolve staged post-mount service names to changelog scopes (e.g. + # ClientApi -> client). dist/ is git-ignored and not produced by npm ci. + run: npm run build:policy + + - name: Render + push the changelog preview + env: + SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }} + PREVIEW_ID: ${{ inputs.preview_id }} + SERVICES: ${{ inputs.services }} + BASE_REF: ${{ inputs.base_ref }} + run: | + set -uo pipefail + # Report the SHA of the ref we actually render the changelog for, so the + # bot's "spec advanced since preview" check compares like-for-like. + BASE_SHA=$(git rev-parse "$BASE_REF") + + post() { # $1 = json file + SIG="sha256=$(openssl dgst -sha256 -hmac "$SPEC_CHANGES_SECRET" "$1" | sed 's/^.*= //')" + for attempt in 1 2 3; do + if curl -sS -X POST "$SDK_BOT_URL/internal/changelog-preview" \ + -H "Content-Type: application/json" \ + -H "X-Spec-Changes-Signature: $SIG" \ + --data-binary @"$1" \ + --fail-with-body; then + echo ""; return 0 + fi + echo "push attempt ${attempt} failed; retrying in 5s..."; sleep 5 + done + echo "::error::could not push changelog preview after 3 attempts"; return 1 + } + + # sdk-release-metadata --spec-commit runs oagen parse/diff internally + # (this spec commit vs the previous spec-changing commit) and emits the + # changelog entries; render-changelog-preview scopes + renders them. + if node scripts/sdk-release-metadata.mjs --spec-commit "$BASE_REF" --format json --output /tmp/entries.json \ + && node scripts/render-changelog-preview.mjs --entries /tmp/entries.json --services "$SERVICES" > /tmp/changelog.md; then + jq -Rs --arg id "$PREVIEW_ID" --arg sha "$BASE_SHA" \ + '{previewId:$id, status:"ready", markdown:., baseSha:$sha}' /tmp/changelog.md > /tmp/preview.json + else + jq -n --arg id "$PREVIEW_ID" '{previewId:$id, status:"failed", error:"changelog analysis failed"}' > /tmp/preview.json + fi + post /tmp/preview.json diff --git a/.github/workflows/generate-prs.yml b/.github/workflows/generate-prs.yml index cd5827f..7226e0b 100644 --- a/.github/workflows/generate-prs.yml +++ b/.github/workflows/generate-prs.yml @@ -30,6 +30,10 @@ on: description: Run generation and classification without creating PRs type: boolean default: false + changelog_override: + description: 'Base64 of an operator-edited changelog (from the dashboard preview) to use for the release-note fragment' + required: false + type: string concurrency: group: sdk-generation @@ -561,7 +565,7 @@ jobs: if: >- success() && inputs.dry_run != true && steps.batch-pr.outputs.skipped == '' && - steps.classify.outputs.changelog_bullets != '' + (steps.classify.outputs.changelog_bullets != '' || inputs.changelog_override != '') working-directory: ${{ env.SDK_CHECKOUT_PATH }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -569,6 +573,7 @@ jobs: BANG: ${{ steps.classify.outputs.rollup_bang }} SUMMARY: ${{ steps.classify.outputs.rollup_summary }} CHANGELOG_BULLETS: ${{ steps.classify.outputs.changelog_bullets }} + CHANGELOG_OVERRIDE_B64: ${{ inputs.changelog_override }} PR_NUMBER: ${{ steps.batch-pr.outputs.pr_number }} PR_URL: ${{ steps.batch-pr.outputs.pr_url }} BRANCH: ${{ steps.batch-pr.outputs.branch }} @@ -579,13 +584,24 @@ jobs: # and the GitHub Release body, then deletes the consumed fragments. # Until that workflow lands per-SDK these fragments accumulate # harmlessly on main. + # + # An operator-edited changelog (from the dashboard "Generate staged" + # preview) takes precedence over the computed bullets. + OVERRIDE_MD="" + if [ -n "$CHANGELOG_OVERRIDE_B64" ]; then + OVERRIDE_MD=$(printf '%s' "$CHANGELOG_OVERRIDE_B64" | base64 -d) + fi FRAGMENT_DIR=".changelog-pending" mkdir -p "$FRAGMENT_DIR" FRAGMENT_PATH="$FRAGMENT_DIR/$(date -u +%Y-%m-%dT%H-%M-%S)-${{ github.sha }}.md" { echo "* [#${PR_NUMBER}](${PR_URL}) ${TYPE}(generated)${BANG}: ${SUMMARY}" echo - printf '%s\n' "$CHANGELOG_BULLETS" + if [ -n "$OVERRIDE_MD" ]; then + printf '%s\n' "$OVERRIDE_MD" + else + printf '%s\n' "$CHANGELOG_BULLETS" + fi } > "$FRAGMENT_PATH" git add "$FRAGMENT_PATH" git commit -m "chore(generated): add release notes fragment" diff --git a/.github/workflows/spec-changes.yml b/.github/workflows/spec-changes.yml index 06fa753..129a985 100644 --- a/.github/workflows/spec-changes.yml +++ b/.github/workflows/spec-changes.yml @@ -94,16 +94,31 @@ jobs: if: steps.parent.outputs.available == 'true' env: SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }} + HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }} + REPO: ${{ github.repository }} run: | set -euo pipefail + # Commit subject (first line) + originating PR number, for the + # dashboard's "spec commits / PRs" pane. Squash merges put `(#NN)` in + # the subject; absent that, prNumber is simply omitted. + SUBJECT=$(printf '%s\n' "$HEAD_COMMIT_MSG" | head -1) + PR_NUM=$(printf '%s' "$SUBJECT" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) + + ARGS=( + --report /tmp/diff-report.json + --old-ir /tmp/previous-ir.json + --new-ir /tmp/current-ir.json + --sha "${{ github.sha }}" + --parent-sha "${{ steps.parent.outputs.parent }}" + --commit-message "$SUBJECT" + ) + if [ -n "$PR_NUM" ]; then + ARGS+=(--pr-number "$PR_NUM" --pr-url "https://github.com/$REPO/pull/$PR_NUM") + fi + # build-spec-changes.mjs writes the manifest to stdout when --output is # omitted. Capture it, then HMAC-sign the exact bytes we POST. - MANIFEST=$(node scripts/build-spec-changes.mjs \ - --report /tmp/diff-report.json \ - --old-ir /tmp/previous-ir.json \ - --new-ir /tmp/current-ir.json \ - --sha "${{ github.sha }}" \ - --parent-sha "${{ steps.parent.outputs.parent }}") + MANIFEST=$(node scripts/build-spec-changes.mjs "${ARGS[@]}") echo "Manifest:" echo "$MANIFEST" | jq . diff --git a/scripts/__tests__/build-spec-changes.spec.mjs b/scripts/__tests__/build-spec-changes.spec.mjs index 052e97b..c307ab7 100644 --- a/scripts/__tests__/build-spec-changes.spec.mjs +++ b/scripts/__tests__/build-spec-changes.spec.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { buildSpecChanges, + buildChangedEndpoints, buildSymbolOwners, isBreaking, servicesForChange, @@ -116,6 +117,40 @@ test('behaviorChanges fold into their service as breaking', () => { assert.deepEqual(manifest.changedServices, [{ service: 'UserManagement', hasBreaking: true }]); }); +// ── buildChangedEndpoints: method/path from IR, post-mount attribution ─────── +test('buildChangedEndpoints resolves method/path and attributes to post-mount service', () => { + const ir = { + services: [ + { + name: 'UserManagementUsers', + operations: [ + { name: 'createUser', httpMethod: 'post', path: '/user_management/users' }, + { name: 'deleteUser', httpMethod: 'delete', path: '/user_management/users/{id}' }, + ], + }, + ], + }; + const report = { + changes: [ + { kind: 'operation-added', serviceName: 'UserManagementUsers', operationName: 'createUser', classification: 'additive' }, + { kind: 'operation-removed', serviceName: 'UserManagementUsers', operationName: 'deleteUser', classification: 'breaking' }, + ], + }; + const map = buildChangedEndpoints({ report, irs: [ir, ir], mountRules: MOUNT_RULES }); + assert.deepEqual(map.get('UserManagement'), [ + { method: 'POST', path: '/user_management/users', breaking: false, kind: 'operation-added' }, + { method: 'DELETE', path: '/user_management/users/{id}', breaking: true, kind: 'operation-removed' }, + ]); +}); + +test('buildChangedEndpoints skips changes whose endpoint is absent from the IR', () => { + const report = { + changes: [{ kind: 'operation-added', serviceName: 'Vault', operationName: 'mystery', classification: 'additive' }], + }; + const map = buildChangedEndpoints({ report, irs: [], mountRules: MOUNT_RULES }); + assert.equal(map.size, 0); +}); + // ── isBreaking: trust the rollup, but defend against missing classification ── test('isBreaking trusts top-level classification', () => { assert.equal(isBreaking({ kind: 'operation-modified', classification: 'breaking' }), true); diff --git a/scripts/__tests__/render-changelog-preview.spec.mjs b/scripts/__tests__/render-changelog-preview.spec.mjs new file mode 100644 index 0000000..1567271 --- /dev/null +++ b/scripts/__tests__/render-changelog-preview.spec.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { scopesForStaged } from '../render-changelog-preview.mjs'; + +// Representative slice of the real mount-rules (Client mounts to ClientApi). +const MOUNT_RULES = { + Client: 'ClientApi', + Permissions: 'Authorization', + Connections: 'SSO', + UserManagementUsers: 'UserManagement', + UserManagementDataProviders: 'Pipes', +}; + +// Regression: staging the post-mount ClientApi must include the `client` scope +// the changelog entries actually use — publicScopeFromService('ClientApi') is +// 'client_api', so without the mount-aware union a ClientApi-only preview would +// filter out every (client-scoped) entry and render blank. +test('staged ClientApi resolves to the client scope, not just client_api', () => { + const scopes = scopesForStaged(['ClientApi'], MOUNT_RULES); + assert.ok(scopes.has('client'), 'includes client (from the Client -> ClientApi mount)'); +}); + +test('a directly-named service resolves to its own scope', () => { + const scopes = scopesForStaged(['SSO'], MOUNT_RULES); + assert.ok(scopes.has('sso')); +}); + +test('a post-mount parent picks up the scopes of its mounted sub-services', () => { + // UserManagementUsers + UserManagementDataProviders mount to UserManagement / + // Pipes; staging UserManagement should at least include user_management. + const scopes = scopesForStaged(['UserManagement'], MOUNT_RULES); + assert.ok(scopes.has('user_management')); +}); + +test('no mountRules → still resolves the post-mount name to its own scope', () => { + const scopes = scopesForStaged(['Vault'], {}); + assert.ok(scopes.has('vault')); +}); + +// Representative slice of the real operation hints: the audit-log-retention ops +// live under /organizations (changelog scope `organizations`) but mount on +// AuditLogs via per-operation `mountOn`. +const OPERATION_HINTS = { + 'GET /organizations/{id}/audit_logs_retention': { name: 'get_organization_audit_logs_retention', mountOn: 'AuditLogs' }, + 'PUT /organizations/{id}/audit_logs_retention': { mountOn: 'AuditLogs' }, +}; + +// Regression: staging AuditLogs must include the `organizations` scope of the +// retention ops mounted onto it via `mountOn` — without the hint-aware union an +// AuditLogs-only preview would drop those staged entries and render blank. +test('staged AuditLogs picks up the organizations scope of mountOn-remounted ops', () => { + const scopes = scopesForStaged(['AuditLogs'], MOUNT_RULES, OPERATION_HINTS); + assert.ok(scopes.has('audit_logs'), 'includes its own scope'); + assert.ok(scopes.has('organizations'), 'includes the source scope of the remounted ops'); +}); + +test('mountOn hints for non-staged targets do not leak scopes', () => { + const scopes = scopesForStaged(['SSO'], MOUNT_RULES, OPERATION_HINTS); + assert.ok(!scopes.has('organizations'), 'AuditLogs not staged → no organizations scope'); +}); diff --git a/scripts/build-spec-changes.mjs b/scripts/build-spec-changes.mjs index f5a46a8..4460564 100644 --- a/scripts/build-spec-changes.mjs +++ b/scripts/build-spec-changes.mjs @@ -43,6 +43,9 @@ function parseArgs(argv) { sha: "", parentSha: "", timestamp: "", + commitMessage: "", + prNumber: "", + prUrl: "", output: "", }; for (let i = 2; i < argv.length; i += 1) { @@ -53,6 +56,9 @@ function parseArgs(argv) { else if (arg === "--sha") args.sha = argv[++i] ?? ""; else if (arg === "--parent-sha") args.parentSha = argv[++i] ?? ""; else if (arg === "--timestamp") args.timestamp = argv[++i] ?? ""; + else if (arg === "--commit-message") args.commitMessage = argv[++i] ?? ""; + else if (arg === "--pr-number") args.prNumber = argv[++i] ?? ""; + else if (arg === "--pr-url") args.prUrl = argv[++i] ?? ""; else if (arg === "--output") args.output = argv[++i] ?? ""; else throw new Error(`Unknown option: ${arg}`); } @@ -191,6 +197,49 @@ export function servicesForChange(change, owners, mountRules) { return []; } +// ── changed endpoints (dashboard drill-in) ──────────────────────────────────── +// The endpoints that changed, grouped by post-mount service, each with the +// canonical breaking flag. Method/path come from the IR (operations carry +// httpMethod + path); the diff report only names serviceName.operationName. +// Kept separate from buildSpecChanges so the lean changedServices rollup the +// bot's pending logic depends on stays shape-stable; the CLI merges these in. +export function buildChangedEndpoints({ report, irs = [], mountRules = {} }) { + const opByKey = new Map(); // "ServiceName.opName" -> { method, path } + for (const ir of irs.filter(Boolean)) { + for (const service of ir.services ?? []) { + for (const op of service.operations ?? []) { + if (op.httpMethod && op.path) { + opByKey.set(`${service.name}.${op.name}`, { + method: String(op.httpMethod).toUpperCase(), + path: op.path, + }); + } + } + } + } + + const byService = new Map(); // postMountService -> Map<"METHOD path", endpoint> + for (const change of report.changes ?? []) { + const kind = typeof change?.kind === "string" ? change.kind : ""; + if (!kind.startsWith("operation-")) continue; + const ep = opByKey.get(`${change.serviceName}.${change.operationName}`); + if (!ep) continue; + const service = toPostMount(change.serviceName, mountRules); + if (!service) continue; + if (!byService.has(service)) byService.set(service, new Map()); + const endpoints = byService.get(service); + const key = `${ep.method} ${ep.path}`; + const breaking = isBreaking(change); + const existing = endpoints.get(key); + if (!existing) endpoints.set(key, { method: ep.method, path: ep.path, breaking, kind }); + else if (breaking) existing.breaking = true; + } + + const out = new Map(); + for (const [service, endpoints] of byService) out.set(service, [...endpoints.values()]); + return out; +} + // ── build the manifest ─────────────────────────────────────────────────────── export function buildSpecChanges({ report, @@ -271,6 +320,18 @@ async function main() { mountRules, }); + // Enrich the emitted manifest (the bot reads these; the lean buildSpecChanges + // rollup above is intentionally left untouched): per-service changed endpoints + // and the originating commit/PR. + const endpointsByService = buildChangedEndpoints({ report, irs, mountRules }); + manifest.changedServices = manifest.changedServices.map((s) => ({ + ...s, + changedEndpoints: endpointsByService.get(s.service) ?? [], + })); + if (args.commitMessage) manifest.commitMessage = args.commitMessage; + if (args.prNumber && /^\d+$/.test(args.prNumber)) manifest.prNumber = Number(args.prNumber); + if (args.prUrl) manifest.prUrl = args.prUrl; + if (unattributedSymbolChanges > 0) { const reason = irs.some(Boolean) ? "these models/enums are referenced by no operation (orphaned) or absent from the supplied IR" diff --git a/scripts/render-changelog-preview.mjs b/scripts/render-changelog-preview.mjs new file mode 100644 index 0000000..d4d8fea --- /dev/null +++ b/scripts/render-changelog-preview.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// +// render-changelog-preview.mjs +// +// Render the changelog scoped to a staged service set, using the SAME +// renderChangelogMarkdown logic generate-prs.yml uses — so the dashboard's +// "Generate staged" preview matches what would ship. Reads the `entries` JSON +// from `sdk-release-metadata.mjs --format json` and keeps only the entries whose +// scope belongs to one of the requested services, then prints markdown. +// +// node scripts/render-changelog-preview.mjs --entries --services "SSO,Vault" + +import { existsSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { publicScopeFromService, renderChangelogMarkdown } from "./sdk-release-metadata.mjs"; + +function parseArgs(argv) { + const args = { entries: "", services: "" }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--entries") args.entries = argv[++i] ?? ""; + else if (arg === "--services") args.services = argv[++i] ?? ""; + else throw new Error(`Unknown option: ${arg}`); + } + if (!args.entries) { + throw new Error("Usage: render-changelog-preview.mjs --entries [--services ]"); + } + return args; +} + +// The changelog scope an operation's entry carries, derived from its path key +// (`"METHOD /path"`). The producer scopes operation entries by their IR service +// name via publicScopeFromService; WorkOS IR service names are the PascalCased +// leading path segment (`/user_management/...` -> UserManagement, +// `/audit_logs/...` -> AuditLogs, `/directory_groups` -> DirectorySync), so +// running that same segment through publicScopeFromService reproduces the +// producer's scope without re-encoding the scope table. +function scopeFromOperationKey(opKey) { + const slash = opKey.indexOf("/"); + if (slash === -1) return "sdk"; + const head = opKey.slice(slash).split("/").filter(Boolean)[0] ?? ""; + const pascal = head + .split(/[-_]/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); + return publicScopeFromService(pascal); +} + +// The changelog scopes for a set of staged POST-mount service names. +// publicScopeFromService is defined over PRE-mount (IR) names; for post-mount +// names it coincides for all but the mounted ones — e.g. IR `Client` mounts to +// `ClientApi`, but its changelog scope is `client`, not `client_api`. So union +// each post-mount name's own scope with the scopes of every IR name that mounts +// to it, using the same mountRules the producer/generator use (no drift). +// +// Services are also assembled from per-operation `mountOn` hints, which can pull +// an operation in from a different source scope: the audit-log-retention ops +// live under /organizations (scope `organizations`) but mount on AuditLogs, so +// an AuditLogs-only preview must include `organizations` or it renders blank. +export function scopesForStaged(staged, mountRules = {}, operationHints = {}) { + const scopes = new Set(); + for (const s of staged) scopes.add(publicScopeFromService(s)); + for (const [pre, post] of Object.entries(mountRules)) { + if (staged.includes(post)) scopes.add(publicScopeFromService(pre)); + } + for (const [opKey, hint] of Object.entries(operationHints)) { + if (hint?.mountOn && staged.includes(hint.mountOn)) { + scopes.add(scopeFromOperationKey(opKey)); + } + } + return scopes; +} + +async function loadPolicy() { + try { + const mod = await import(new URL("../dist/policy.mjs", import.meta.url)); + return { mountRules: mod.mountRules ?? {}, operationHints: mod.operationHints ?? {} }; + } catch { + // Fall back to post-mount-name scopes only (the workflow builds dist/policy + // first, so this only degrades a local run without `npm run build:policy`). + return { mountRules: {}, operationHints: {} }; + } +} + +async function main() { + const args = parseArgs(process.argv); + const entries = existsSync(args.entries) ? JSON.parse(readFileSync(args.entries, "utf8")) : []; + const staged = String(args.services ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + // With no staged set, render everything. + if (staged.length === 0) { + process.stdout.write(renderChangelogMarkdown(entries, {})); + return; + } + const { mountRules, operationHints } = await loadPolicy(); + const scopes = scopesForStaged(staged, mountRules, operationHints); + const filtered = entries.filter((e) => scopes.has(e.scope)); + process.stdout.write(renderChangelogMarkdown(filtered, {})); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} diff --git a/scripts/sdk-release-metadata.mjs b/scripts/sdk-release-metadata.mjs index eac19b1..bc0d900 100644 --- a/scripts/sdk-release-metadata.mjs +++ b/scripts/sdk-release-metadata.mjs @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; const SCOPE_LABELS = { admin_portal: 'admin portal', @@ -366,7 +367,7 @@ function sortStable(values) { return [...values].sort((a, b) => a.localeCompare(b)); } -function publicScopeFromService(serviceName) { +export function publicScopeFromService(serviceName) { if (!serviceName) return 'sdk'; if (SERVICE_SCOPE_OVERRIDES.has(serviceName)) return SERVICE_SCOPE_OVERRIDES.get(serviceName); if (serviceName.startsWith('Directory')) return 'directory_sync'; @@ -1145,7 +1146,7 @@ function rollupForEntries(entries) { return { type: 'fix', bang: '' }; } -function renderChangelogMarkdown(entries, args) { +export function renderChangelogMarkdown(entries, args) { const lines = []; const rollup = rollupForEntries(entries); const count = entries.length; @@ -1175,37 +1176,42 @@ function renderChangelogMarkdown(entries, args) { return `${lines.join('\n')}\n`; } -let args = parseArgs(process.argv.slice(2)); -if (args.help) { - printHelp(); - process.exit(0); -} -args = prepareSpecInputs(args); -args = prepareCompatInputs(args); - -try { - const diffReport = readJson(args['diff-report'], { changes: [], behaviorChanges: [], summary: {} }); - const oldIr = readJson(args['old-ir'], null); - const newIr = readJson(args['new-ir'], null); - const compatReport = readJson(args['compat-report'], { changes: [] }); - const changedFiles = changedFilesFromArgs(args); - - const indexes = buildIndexes([oldIr, newIr]); - const specFacts = factsFromDiff(diffReport, indexes); - const compatFacts = factsFromCompat(compatReport, specFacts, indexes); - const entries = entriesFromGroups(groupFacts([...specFacts, ...compatFacts]), changedFiles); - reportScopeValidation(entries, args); - - const output = - args.format === 'changelog' || args.format === 'markdown' - ? renderChangelogMarkdown(entries, args) - : `${JSON.stringify(entries, null, 2)}\n`; - if (args.output) { - writeFileSync(args.output, output); - } else { - process.stdout.write(output); +// Only run the CLI when invoked directly, so the helpers above +// (publicScopeFromService, renderChangelogMarkdown) can be imported — e.g. by +// render-changelog-preview.mjs — without executing the CLI. +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + let args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + process.exit(0); + } + args = prepareSpecInputs(args); + args = prepareCompatInputs(args); + + try { + const diffReport = readJson(args['diff-report'], { changes: [], behaviorChanges: [], summary: {} }); + const oldIr = readJson(args['old-ir'], null); + const newIr = readJson(args['new-ir'], null); + const compatReport = readJson(args['compat-report'], { changes: [] }); + const changedFiles = changedFilesFromArgs(args); + + const indexes = buildIndexes([oldIr, newIr]); + const specFacts = factsFromDiff(diffReport, indexes); + const compatFacts = factsFromCompat(compatReport, specFacts, indexes); + const entries = entriesFromGroups(groupFacts([...specFacts, ...compatFacts]), changedFiles); + reportScopeValidation(entries, args); + + const output = + args.format === 'changelog' || args.format === 'markdown' + ? renderChangelogMarkdown(entries, args) + : `${JSON.stringify(entries, null, 2)}\n`; + if (args.output) { + writeFileSync(args.output, output); + } else { + process.stdout.write(output); + } + } finally { + if (args._tmpdir) rmSync(args._tmpdir, { recursive: true, force: true }); + if (args._compatTmpdir) rmSync(args._compatTmpdir, { recursive: true, force: true }); } -} finally { - if (args._tmpdir) rmSync(args._tmpdir, { recursive: true, force: true }); - if (args._compatTmpdir) rmSync(args._compatTmpdir, { recursive: true, force: true }); }