From c311fe8aa654f31fe2231e054175d6e02372e6b2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 24 Jun 2026 09:59:25 -0400 Subject: [PATCH 1/3] feat: enrich spec-changes manifest + changelog preview for the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the spec-changes producer the SDK bot consumes so the redesigned dashboard can show changed endpoints, the originating commit/PR, and an editable changelog before generation: - build-spec-changes.mjs: emit per-service changedEndpoints (method/path/breaking resolved from the IR, post-mount attributed) and the manifest's commitMessage/prNumber/prUrl. The lean buildSpecChanges rollup the bot's pending logic depends on is unchanged — enrichment happens only in the CLI, so the existing tests stay green. - spec-changes.yml: pass the commit subject + originating PR number through. - changelog-preview.yml + render-changelog-preview.mjs: on-demand workflow that renders the changelog scoped to a staged service set with the canonical renderChangelogMarkdown logic and POSTs it to the bot (HMAC-signed) for the "Generate staged" preview. - sdk-release-metadata.mjs: export publicScopeFromService + renderChangelogMarkdown and guard the CLI behind a direct-invocation check so they're importable. - generate-prs.yml: changelog_override input writes the operator-edited changelog into the .changelog-pending fragment instead of the computed bullets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/changelog-preview.yml | 88 +++++++++++++++++++ .github/workflows/generate-prs.yml | 20 ++++- .github/workflows/spec-changes.yml | 27 ++++-- scripts/__tests__/build-spec-changes.spec.mjs | 35 ++++++++ scripts/build-spec-changes.mjs | 61 +++++++++++++ scripts/render-changelog-preview.mjs | 51 +++++++++++ scripts/sdk-release-metadata.mjs | 74 +++++++++------- 7 files changed, 314 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/changelog-preview.yml create mode 100644 scripts/render-changelog-preview.mjs diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 0000000..279f028 --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,88 @@ +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: 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 }} + BASE_SHA: ${{ github.sha }} + run: | + set -uo pipefail + + 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/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..381fa40 --- /dev/null +++ b/scripts/render-changelog-preview.mjs @@ -0,0 +1,51 @@ +#!/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; +} + +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); + const scopes = new Set(staged.map((s) => publicScopeFromService(s))); + // Scope to the staged services; with none given, render everything. + const filtered = staged.length ? entries.filter((e) => scopes.has(e.scope)) : entries; + process.stdout.write(renderChangelogMarkdown(filtered, {})); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + 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 }); } From fb71f552f39a8e5fd951ce4a47bf023bbd0f1f75 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 24 Jun 2026 13:04:52 -0400 Subject: [PATCH 2/3] fix: resolve staged scopes via mountRules + report the rendered base SHA Address Greptile review on #39: - render-changelog-preview.mjs: map staged post-mount service names to changelog scopes through mountRules, not publicScopeFromService alone. ClientApi's scope is `client` (IR `Client` mounts to `ClientApi`), so a ClientApi-only preview was filtering on `client_api` and rendering blank. scopesForStaged is exported and unit-tested; changelog-preview.yml now builds dist/policy.mjs for the rules. - changelog-preview.yml: report baseSha as `git rev-parse "$BASE_REF"` (the ref actually rendered) instead of github.sha, so the bot's "spec advanced since preview" check compares like-for-like. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/changelog-preview.yml | 10 ++++- .../render-changelog-preview.spec.mjs | 39 ++++++++++++++++ scripts/render-changelog-preview.mjs | 44 +++++++++++++++---- 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 scripts/__tests__/render-changelog-preview.spec.mjs diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 279f028..e85173a 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -50,15 +50,23 @@ jobs: - 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 }} - BASE_SHA: ${{ github.sha }} 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/^.*= //')" diff --git a/scripts/__tests__/render-changelog-preview.spec.mjs b/scripts/__tests__/render-changelog-preview.spec.mjs new file mode 100644 index 0000000..fcda121 --- /dev/null +++ b/scripts/__tests__/render-changelog-preview.spec.mjs @@ -0,0 +1,39 @@ +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')); +}); diff --git a/scripts/render-changelog-preview.mjs b/scripts/render-changelog-preview.mjs index 381fa40..8241373 100644 --- a/scripts/render-changelog-preview.mjs +++ b/scripts/render-changelog-preview.mjs @@ -28,24 +28,52 @@ function parseArgs(argv) { return args; } -function main() { +// 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). +export function scopesForStaged(staged, mountRules = {}) { + 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)); + } + return scopes; +} + +async function loadMountRules() { + try { + const mod = await import(new URL("../dist/policy.mjs", import.meta.url)); + return mod.mountRules ?? {}; + } 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 {}; + } +} + +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); - const scopes = new Set(staged.map((s) => publicScopeFromService(s))); - // Scope to the staged services; with none given, render everything. - const filtered = staged.length ? entries.filter((e) => scopes.has(e.scope)) : entries; + // With no staged set, render everything. + if (staged.length === 0) { + process.stdout.write(renderChangelogMarkdown(entries, {})); + return; + } + const scopes = scopesForStaged(staged, await loadMountRules()); + const filtered = entries.filter((e) => scopes.has(e.scope)); process.stdout.write(renderChangelogMarkdown(filtered, {})); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - try { - main(); - } catch (err) { + main().catch((err) => { process.stderr.write(`${err.message}\n`); process.exit(1); - } + }); } From ac5564297a6e205dd9461b429c4214cdc2ed3389 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 24 Jun 2026 14:11:38 -0400 Subject: [PATCH 3/3] fix: include mountOn-remounted scopes in changelog preview scopesForStaged only followed service-level mountRules, so per-operation mountOn hints (e.g. the audit-log-retention ops live under /organizations but mount on AuditLogs) were dropped: an AuditLogs-only preview rendered blank for those staged entries. Expand the scope set with each mountOn hint whose target is staged, deriving the operation's source scope from its path head through the same publicScopeFromService the producer uses. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../render-changelog-preview.spec.mjs | 22 ++++++++++ scripts/render-changelog-preview.mjs | 40 ++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/render-changelog-preview.spec.mjs b/scripts/__tests__/render-changelog-preview.spec.mjs index fcda121..1567271 100644 --- a/scripts/__tests__/render-changelog-preview.spec.mjs +++ b/scripts/__tests__/render-changelog-preview.spec.mjs @@ -37,3 +37,25 @@ 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/render-changelog-preview.mjs b/scripts/render-changelog-preview.mjs index 8241373..d4d8fea 100644 --- a/scripts/render-changelog-preview.mjs +++ b/scripts/render-changelog-preview.mjs @@ -28,29 +28,58 @@ function parseArgs(argv) { 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). -export function scopesForStaged(staged, mountRules = {}) { +// +// 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 loadMountRules() { +async function loadPolicy() { try { const mod = await import(new URL("../dist/policy.mjs", import.meta.url)); - return mod.mountRules ?? {}; + 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 {}; + return { mountRules: {}, operationHints: {} }; } } @@ -66,7 +95,8 @@ async function main() { process.stdout.write(renderChangelogMarkdown(entries, {})); return; } - const scopes = scopesForStaged(staged, await loadMountRules()); + 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, {})); }