Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/changelog-preview.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
else
jq -n --arg id "$PREVIEW_ID" '{previewId:$id, status:"failed", error:"changelog analysis failed"}' > /tmp/preview.json
fi
post /tmp/preview.json
20 changes: 18 additions & 2 deletions .github/workflows/generate-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -561,14 +565,15 @@ 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 }}
TYPE: ${{ steps.classify.outputs.rollup_type }}
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 }}
Expand All @@ -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"
Expand Down
27 changes: 21 additions & 6 deletions .github/workflows/spec-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .

Expand Down
35 changes: 35 additions & 0 deletions scripts/__tests__/build-spec-changes.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from 'node:test';

import {
buildSpecChanges,
buildChangedEndpoints,
buildSymbolOwners,
isBreaking,
servicesForChange,
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions scripts/__tests__/render-changelog-preview.spec.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
61 changes: 61 additions & 0 deletions scripts/build-spec-changes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ function parseArgs(argv) {
sha: "",
parentSha: "",
timestamp: "",
commitMessage: "",
prNumber: "",
prUrl: "",
output: "",
};
for (let i = 2; i < argv.length; i += 1) {
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

const out = new Map();
for (const [service, endpoints] of byService) out.set(service, [...endpoints.values()]);
return out;
}

// ── build the manifest ───────────────────────────────────────────────────────
export function buildSpecChanges({
report,
Expand Down Expand Up @@ -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"
Expand Down
Loading