Skip to content

Generate PRs (batch preview-fa077002) #114

Generate PRs (batch preview-fa077002)

Generate PRs (batch preview-fa077002) #114

Workflow file for this run

name: Generate PRs
# Surface the batch id in the run's display title so the SDK bot dashboard can
# identify THIS batch's run by name (run.display_title) instead of guessing by
# timestamp. Falls back to the plain workflow name for non-dispatch runs.
run-name: ${{ inputs.batch_id && format('Generate PRs (batch {0})', inputs.batch_id) || 'Generate PRs' }}
on:
# Batches are dispatched by the SDK bot
# via workflow_dispatch with an explicit `services` scope and `batch_id`.
workflow_dispatch:
inputs:
languages:
description: Languages to generate
type: choice
default: all
options:
- all
- dotnet
- go
- ios
- kotlin
- php
- python
- ruby
- rust
services:
description: 'Comma-separated post-mount service names (empty = all)'
required: false
type: string
batch_id:
description: 'Caller-supplied batch id (used for branch oagen/batch-<id>)'
required: true
type: string
dry_run:
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-${{ inputs.batch_id }}
cancel-in-progress: true
jobs:
load-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/sdk-matrix.json
sparse-checkout-cone-mode: false
- name: Load SDK matrix
id: set-matrix
run: |
LANG="${{ inputs.languages }}"
if [ -z "$LANG" ] || [ "$LANG" = "all" ]; then
echo "matrix=$(jq -c . .github/sdk-matrix.json)" >> "$GITHUB_OUTPUT"
else
echo "matrix=$(jq -c --arg lang "$LANG" '[.[] | select(.language == $lang)]' .github/sdk-matrix.json)" >> "$GITHUB_OUTPUT"
fi
generate_pr:
name: generate_pr (${{ matrix.language }})
needs: load-matrix
# Generation itself is pure text emission and runs anywhere, so the real-PR
# path stays on cheap Linux for every language. Previews additionally run the
# SDK's own script/ci below, which needs the platform the SDK actually
# targets (ios: swift build imports CryptoKit, Apple-only — no such module on
# Linux), so honour sdk-matrix.json's `runner` in dry_run only. macOS runners
# bill 10x minutes; validate-sdks.yml always honours `runner` because it only
# exists to run that CI.
runs-on: ${{ (inputs.dry_run && matrix.runner) || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.load-matrix.outputs.matrix) }}
steps:
- name: Resolve SDK checkout path
run: |
SDK_REPO_NAME="$(basename "${{ matrix.sdk_repo }}")"
echo "SDK_REPO_NAME=$SDK_REPO_NAME" >> "$GITHUB_ENV"
echo "SDK_CHECKOUT_PATH=backend/$SDK_REPO_NAME" >> "$GITHUB_ENV"
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.SDK_BOT_APP_ID }}
private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }}
owner: workos
repositories: ${{ env.SDK_REPO_NAME }}
permission-contents: write
permission-pull-requests: write
- name: Checkout openapi-spec
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: openapi-spec
fetch-depth: 50
- name: Checkout live SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ matrix.sdk_repo }}
token: ${{ steps.app-token.outputs.token }}
path: ${{ env.SDK_CHECKOUT_PATH }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: openapi-spec/package-lock.json
- name: Install dependencies
working-directory: openapi-spec
run: npm ci
- name: Setup SDK runtime
uses: ./openapi-spec/.github/actions/setup-sdk-runtime
with:
language: ${{ matrix.language }}
sdk-path: ${{ env.SDK_CHECKOUT_PATH }}
- name: Extract baseline SDK snapshot
continue-on-error: true
working-directory: openapi-spec
run: |
npm run sdk:compat-extract -- \
--lang ${{ matrix.language }} \
--sdk-root "$GITHUB_WORKSPACE/${{ env.SDK_CHECKOUT_PATH }}"
- name: Generate SDK
working-directory: openapi-spec
env:
SERVICES_INPUT: ${{ inputs.services }}
run: |
# Strip any spaces from the CSV so "Vault, SSO" threads as a single
# quoted --services value instead of word-splitting into a bogus "SSO"
# arg. Empty (full generation) → no --services flag is appended.
SERVICES="${SERVICES_INPUT// /}"
npm run sdk:generate -- \
--lang ${{ matrix.language }} \
--output "$GITHUB_WORKSPACE/${{ env.SDK_CHECKOUT_PATH }}" \
${SERVICES:+--services "$SERVICES"}
# Preview only: capture the generated changes as a unified diff so
# preview_diff_report can render them (mirrors validate-sdks.yml's capture).
# Gated to dry_run so the real-PR path is untouched — this also never stages
# content that could disturb open-batch-pr.mjs's own commit.
- name: Capture SDK code diff (preview)
if: success() && inputs.dry_run == true
working-directory: ${{ env.SDK_CHECKOUT_PATH }}
run: |
mkdir -p "$GITHUB_WORKSPACE/openapi-spec/.oagen/${{ matrix.language }}"
# intent-to-add so untracked generated files appear in the diff
git add -AN
git -c core.quotepath=false diff HEAD \
--src-prefix="a/${{ matrix.language }}/" \
--dst-prefix="b/${{ matrix.language }}/" \
> "$GITHUB_WORKSPACE/openapi-spec/.oagen/${{ matrix.language }}/sdk-code.diff" || true
- name: Extract candidate SDK snapshot
continue-on-error: true
working-directory: openapi-spec
run: |
npm run sdk:compat-extract -- \
--lang ${{ matrix.language }} \
--sdk-root "$GITHUB_WORKSPACE/${{ env.SDK_CHECKOUT_PATH }}" \
--output .oagen/${{ matrix.language }}/sdk
- name: SDK compat diff
id: compat-diff
continue-on-error: true
working-directory: openapi-spec
run: npm run sdk:compat-diff -- --lang ${{ matrix.language }}
- name: Export previous spec snapshot
id: prev-spec
working-directory: openapi-spec
run: |
PREV_COMMIT=$(git log --skip=1 -1 --format=%H -- spec/open-api-spec.yaml)
if [ -z "$PREV_COMMIT" ]; then
echo "No previous spec commit found"
echo "available=false" >> "$GITHUB_OUTPUT"
else
git show "${PREV_COMMIT}:spec/open-api-spec.yaml" > /tmp/previous-open-api-spec.yaml
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Diff report
id: diff-report
if: steps.prev-spec.outputs.available == 'true'
working-directory: openapi-spec
run: |
REPORT=$(npx oagen diff \
--old /tmp/previous-open-api-spec.yaml \
--new spec/open-api-spec.yaml) || DIFF_EXIT=$?
# Exit code 2 means breaking changes were detected — not a failure
if [ "${DIFF_EXIT:-0}" -ne 0 ] && [ "${DIFF_EXIT:-0}" -ne 2 ]; then
echo "::warning::oagen diff failed (exit code ${DIFF_EXIT})"
exit 0
fi
if ! echo "$REPORT" | jq -e '.summary' > /dev/null 2>&1; then
echo "::warning::oagen diff returned invalid JSON"
exit 0
fi
echo "$REPORT" > /tmp/diff-report.json
ADDED=$(echo "$REPORT" | jq -r '.summary.added')
REMOVED=$(echo "$REPORT" | jq -r '.summary.removed')
MODIFIED=$(echo "$REPORT" | jq -r '.summary.modified')
BREAKING=$(echo "$REPORT" | jq -r '.summary.breaking')
{
echo "added=$ADDED"
echo "removed=$REMOVED"
echo "modified=$MODIFIED"
echo "breaking=$BREAKING"
} >> "$GITHUB_OUTPUT"
- name: Build deterministic changelog metadata
id: classify-raw
working-directory: ${{ env.SDK_CHECKOUT_PATH }}
env:
SDK_LANGUAGE: ${{ matrix.language }}
SERVICES_INPUT: ${{ inputs.services }}
run: |
# Stage everything so new (untracked) files appear in the diff
git add -A
git diff --cached --name-only > /tmp/sdk-changed-files.txt
# Pass the structured oagen diff as the release-note source of truth.
SPEC_DIFF="{}"
BEHAVIOR_CHANGES="[]"
if [ -f /tmp/diff-report.json ]; then
SPEC_DIFF=$(cat /tmp/diff-report.json)
# Behavior changes (e.g. removed query-param defaults) are emitted as
# a separate top-level array by oagen. They are mechanically
# rendered below as feat(scope)!: change default lines so they don't
# depend on prose inference from the raw SDK diff.
BEHAVIOR_CHANGES=$(echo "$SPEC_DIFF" | jq -c '.behaviorChanges // []')
(
cd "$GITHUB_WORKSPACE/openapi-spec"
npx oagen parse --spec /tmp/previous-open-api-spec.yaml > /tmp/previous-ir.json
npx oagen parse --spec spec/open-api-spec.yaml > /tmp/current-ir.json
)
else
echo '{"changes":[],"behaviorChanges":[],"summary":{}}' > /tmp/diff-report.json
echo '{"models":[],"enums":[],"services":[]}' > /tmp/previous-ir.json
echo '{"models":[],"enums":[],"services":[]}' > /tmp/current-ir.json
fi
# SDK-shape compat-diff signal. The spec diff only sees operations,
# fields, params, models — it is blind to SDK-side reshaping like
# service splits, accessor renames, or *Json → bare model
# consolidation. compat-diff extracts the generated SDK's surface and
# diffs it, so removed/renamed callables and removed accessors show up
# here even when the spec is unchanged.
COMPAT_REPORT_PATH="$GITHUB_WORKSPACE/openapi-spec/.oagen/$SDK_LANGUAGE/compat-report.json"
COMPAT_BREAKING="[]"
COMPAT_BREAKING_SCOPES="[]"
if [ -f "$COMPAT_REPORT_PATH" ]; then
COMPAT_BREAKING=$(jq -c '[.changes[]? | select(.severity == "breaking") | {category, symbol, message: (.message // "")}]' "$COMPAT_REPORT_PATH")
# Log each breaking symbol's root for diagnostics.
COMPAT_BREAKING_SCOPES=$(echo "$COMPAT_BREAKING" | jq -c '[.[] | .symbol | split(".")[0] | gsub("(?<a>[a-z0-9])(?<b>[A-Z])"; .a + "_" + .b) | ascii_downcase] | unique')
echo "Compat breaking count: $(echo "$COMPAT_BREAKING" | jq 'length'), scopes: $COMPAT_BREAKING_SCOPES"
fi
OVERRIDE_BLOCK=""
DESCRIPTIONS=""
BEHAVIOR_OVERRIDE_LINES=""
BEHAVIOR_DESCRIPTIONS=""
ENTRY_OVERRIDE_LINES=""
ENTRY_DESCRIPTIONS=""
ROLLUP_TYPE=""
ROLLUP_BANG=""
ROLLUP_SUMMARY=""
# Pre-render behavior changes deterministically. We group by
# serviceName so a spec-wide change (like removing the pagination
# `order` default from every list endpoint) collapses to a single
# commit-override line per service rather than 30 duplicates.
BEHAVIOR_COUNT=$(echo "$BEHAVIOR_CHANGES" | jq 'length')
if [ "${BEHAVIOR_COUNT:-0}" -gt 0 ]; then
# Collapse all behavior changes for the same param across services
# into a single commit-override line, e.g.
# feat!: change default for order in list operations (desc → <unset>)
# so release-please bumps the major version once with a clear
# reason rather than 23 near-duplicate entries. The PR body then
# itemizes which services were touched.
BEHAVIOR_OVERRIDE_LINES=$(echo "$BEHAVIOR_CHANGES" | jq -r '
group_by(.paramName) |
.[] |
(.[0].paramName) as $param |
(.[0].oldDefault // "<unset>") as $old |
(.[0].newDefault // "<unset>") as $new |
(length) as $count |
if $count > 1 then
"feat!: change default for \($param) in list operations (\($old) → \($new))"
else
(.[0].serviceName | gsub("(?<a>[a-z])(?<b>[A-Z])"; .a + "_" + .b) | ascii_downcase) as $scope |
"feat(\($scope))!: change default for \($param) (\($old) → \($new))"
end
')
BEHAVIOR_DESCRIPTIONS=$(echo "$BEHAVIOR_CHANGES" | jq -r '
group_by(.paramName) |
.[] |
(.[0].paramName) as $param |
(.[0].oldDefault // "<unset>") as $old |
(.[0].newDefault // "<unset>") as $new |
(length) as $count |
([.[] | (.serviceName | gsub("(?<a>[a-z])(?<b>[A-Z])"; .a + "_" + .b) | ascii_downcase)] | unique) as $scopes |
"### feat!: change default for \($param) (\($old) → \($new))\n\n- Server-side default for `\($param)` changed across \($count) list operation(s) in: \($scopes | map("`" + . + "`") | join(", ")).\n- Callers who did not explicitly set `\($param)` will now receive different server behavior. Set the parameter explicitly to preserve the previous default.\n"
')
fi
# Strip spaces from the CSV so "Vault, SSO" threads as one --services
# value. Empty (full generation) → the flag is omitted and every scope
# is kept; non-empty → the changelog is scoped to the staged services.
SERVICES="${SERVICES_INPUT// /}"
node "$GITHUB_WORKSPACE/openapi-spec/scripts/sdk-release-metadata.mjs" \
--diff-report /tmp/diff-report.json \
--old-ir /tmp/previous-ir.json \
--new-ir /tmp/current-ir.json \
--compat-report "$COMPAT_REPORT_PATH" \
--changed-files /tmp/sdk-changed-files.txt \
--strict-scopes \
${SERVICES:+--services "$SERVICES"} \
--output /tmp/classify-entries.json
ENTRIES=$(cat /tmp/classify-entries.json)
ENTRY_COUNT=$(echo "$ENTRIES" | jq 'length')
echo "Got ${ENTRY_COUNT} deterministic changelog entr(y|ies)"
if [ "$ENTRY_COUNT" -gt 0 ]; then
# Render breaking changes as `feat(scope)!:` (bang after scope, per
# Conventional Commits) rather than `feat!(scope):`.
# Treat the `chore` prefix as a literal, since release-please does
# not bump versions for it.
ENTRY_OVERRIDE_LINES=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "\($type)(\(.scope))\($bang): \(.summary)"')
ENTRY_DESCRIPTIONS=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "### \($type)(\(.scope))\($bang): \(.summary)\n\n\(.description)\n"')
if echo "$ENTRIES" | jq -e '[.[] | select(.prefix == "feat!")] | length > 0' > /dev/null; then
ROLLUP_TYPE="feat"
ROLLUP_BANG="!"
elif echo "$ENTRIES" | jq -e '[.[] | select(.prefix == "feat")] | length > 0' > /dev/null; then
ROLLUP_TYPE="feat"
ROLLUP_BANG=""
else
ROLLUP_TYPE="fix"
ROLLUP_BANG=""
fi
else
ENTRY_OVERRIDE_LINES=""
ENTRY_DESCRIPTIONS=""
echo "No deterministic changelog entries generated"
fi
# Persist intermediate state for the compose step
echo "${ENTRIES:-[]}" > /tmp/classify-entries.json
echo "${BEHAVIOR_CHANGES:-[]}" > /tmp/classify-behavior-changes.json
echo "${COMPAT_BREAKING:-[]}" > /tmp/classify-compat-breaking.json
printf '%s' "${BEHAVIOR_OVERRIDE_LINES:-}" > /tmp/classify-behavior-overrides.txt
printf '%s' "${BEHAVIOR_DESCRIPTIONS:-}" > /tmp/classify-behavior-descs.txt
printf '%s' "${ENTRY_OVERRIDE_LINES:-}" > /tmp/classify-entry-overrides.txt
printf '%s' "${ENTRY_DESCRIPTIONS:-}" > /tmp/classify-entry-descs.txt
printf '%s' "${ROLLUP_TYPE:-}" > /tmp/classify-rollup-type.txt
printf '%s' "${ROLLUP_BANG:-}" > /tmp/classify-rollup-bang.txt
echo "${ENTRY_COUNT:-0}" > /tmp/classify-entry-count.txt
echo "${BEHAVIOR_COUNT:-0}" > /tmp/classify-behavior-count.txt
- name: Compose PR metadata
id: classify
run: |
ENTRIES=$(cat /tmp/classify-entries.json)
BEHAVIOR_CHANGES=$(cat /tmp/classify-behavior-changes.json)
COMPAT_BREAKING=$(cat /tmp/classify-compat-breaking.json)
BEHAVIOR_OVERRIDE_LINES=$(cat /tmp/classify-behavior-overrides.txt)
BEHAVIOR_DESCRIPTIONS=$(cat /tmp/classify-behavior-descs.txt)
ENTRY_OVERRIDE_LINES=$(cat /tmp/classify-entry-overrides.txt)
ENTRY_DESCRIPTIONS=$(cat /tmp/classify-entry-descs.txt)
ROLLUP_TYPE=$(cat /tmp/classify-rollup-type.txt)
ROLLUP_BANG=$(cat /tmp/classify-rollup-bang.txt)
ENTRY_COUNT=$(cat /tmp/classify-entry-count.txt)
BEHAVIOR_COUNT=$(cat /tmp/classify-behavior-count.txt)
# ── Compose final output (behavior changes + deterministic entries) ─
# Behavior changes (e.g. `feat(authorization)!: change default for
# order`) are always breaking, so they force the rollup to feat! and
# take precedence over any softer rollup from additive entries.
if [ -n "$BEHAVIOR_OVERRIDE_LINES" ]; then
ROLLUP_TYPE="feat"
ROLLUP_BANG="!"
fi
# NOTE: compat-diff breaks are already accounted for via the
# deterministic metadata script (which applies compatChangeIsBreaking
# filtering) — its entries drive the rollup at lines 297-306. We
# intentionally do NOT override the rollup from the raw
# COMPAT_BREAKING array here, because backend-only compat changes
# (field_type_changed, return_type_changed, model-property
# symbol_removed on non-service types) are not customer-facing and
# should not force a major bump.
# Combine pre-rendered behavior lines with deterministic per-scope
# entries. Behavior lines come first because they are the most
# consequential (forced major-version bumps).
COMBINED_OVERRIDE=$(printf '%s\n%s' "$BEHAVIOR_OVERRIDE_LINES" "$ENTRY_OVERRIDE_LINES" | sed '/^$/d')
if [ -n "$COMBINED_OVERRIDE" ]; then
OVERRIDE_BLOCK=$(printf 'BEGIN_COMMIT_OVERRIDE\n%s\nEND_COMMIT_OVERRIDE' "$COMBINED_OVERRIDE")
TOTAL_LINES=$(printf '%s\n' "$COMBINED_OVERRIDE" | wc -l | tr -d ' ')
if [ "$TOTAL_LINES" -eq 1 ]; then
ROLLUP_SUMMARY="regenerate from spec (1 change)"
else
ROLLUP_SUMMARY="regenerate from spec (${TOTAL_LINES} changes)"
fi
fi
# Behavior changes get a dedicated section in the PR body so they
# are not buried among scope-level entries.
if [ -n "$BEHAVIOR_DESCRIPTIONS" ]; then
DESCRIPTIONS=$(printf '## BREAKING: Behavior changes\n\n%s\n## Changes\n\n%s' "$BEHAVIOR_DESCRIPTIONS" "$ENTRY_DESCRIPTIONS")
else
DESCRIPTIONS="$ENTRY_DESCRIPTIONS"
fi
# Fallback: no deterministic entries AND no
# behavior changes — degrade to a single rollup line derived from
# the oagen diff summary counts.
if [ -z "$ROLLUP_TYPE" ]; then
BREAKING='${{ steps.diff-report.outputs.breaking }}'
ADDED='${{ steps.diff-report.outputs.added }}'
if [ "${BREAKING:-0}" -gt 0 ] 2>/dev/null; then
# Reaching this branch means the metadata script classified zero
# entries, yet oagen diff still reports breaking changes — i.e. a
# diff kind factsFromDiff doesn't recognize. Default to feat!
# (over-bump is safer than silently shipping a break as a patch),
# but make it loud so the unhandled kind gets investigated.
echo "::warning::No classified release entries, but oagen diff reports ${BREAKING} breaking change(s). Defaulting to feat! (major bump) conservatively — this usually means factsFromDiff doesn't recognize a diff kind. Investigate rather than trusting the bump."
ROLLUP_TYPE="feat"
ROLLUP_BANG="!"
elif [ "${ADDED:-0}" -gt 0 ] 2>/dev/null; then
ROLLUP_TYPE="feat"
ROLLUP_BANG=""
else
ROLLUP_TYPE="fix"
ROLLUP_BANG=""
fi
ROLLUP_SUMMARY="regenerate from spec"
DESCRIPTIONS="Regenerated SDK from spec changes."
fi
# ── Changelog sub-bullets ───────────────────────────────────────
# Rendered for the SDK repo's release workflow to consume as
# `.changelog-pending/<id>.md` fragments. Each fragment becomes a
# top-level "* [#NN](url) ..." bullet (added in the Create PR
# step) with type-grouped sub-content under bolded sub-labels:
# **⚠️ Breaking**, **Features**, **Fixes**.
BREAKING_LINES=""
if [ "${BEHAVIOR_COUNT:-0}" -gt 0 ]; then
BREAKING_LINES=$(echo "$BEHAVIOR_CHANGES" | jq -r '
group_by(.paramName) |
.[] |
(.[0].paramName) as $param |
(.[0].oldDefault // "<unset>") as $old |
(.[0].newDefault // "<unset>") as $new |
(length) as $count |
([.[] | (.serviceName | gsub("(?<a>[a-z])(?<b>[A-Z])"; .a + "_" + .b) | ascii_downcase)] | unique) as $scopes |
if $count > 1 then
" * Change default for `\($param)` in list operations (`\($old)` → `\($new)`)"
else
" * **\($scopes[0]):** Change default for `\($param)` (`\($old)` → `\($new)`)"
end
')
fi
FEATURE_LINES=""
FIX_LINES=""
if [ -n "${ENTRIES:-}" ] && [ "${ENTRY_COUNT:-0}" -gt 0 ]; then
# Render one entry: top-level " * **scope**:" plus the
# deterministic description re-indented as 4-space sub-bullets.
ENTRY_FILTER='
def format_scope:
if (.docs_url // "") == "" then .scope else "[\(.scope)](\(.docs_url))" end;
def format_detail(d):
(d // "")
| split("\n")
| map(select(test("\\S")))
| map(sub("\\.$"; ""))
| map(if test("^\\s*[-*]\\s+") then sub("^\\s*[-*]\\s+"; " * ") else " " + . end)
| join("\n");
.[] | select(.prefix == $kind)
| " * **\(format_scope)**:" as $head
| format_detail(.description) as $detail
| if $detail == "" then "\($head)\n * \(.summary | sub("\\.$"; ""))" else "\($head)\n\($detail)" end
'
ENTRY_BREAKING=$(echo "$ENTRIES" | jq -r --arg kind "feat!" "$ENTRY_FILTER")
if [ -n "$ENTRY_BREAKING" ]; then
BREAKING_LINES=$(printf '%s\n%s' "$BREAKING_LINES" "$ENTRY_BREAKING" | sed '/^$/d')
fi
FEATURE_LINES=$(echo "$ENTRIES" | jq -r --arg kind "feat" "$ENTRY_FILTER")
FIX_LINES=$(echo "$ENTRIES" | jq -r --arg kind "fix" "$ENTRY_FILTER")
fi
# Assemble fragment body. Only non-empty groups appear. Blank
# line between groups so markdown renders each sub-label as its
# own paragraph inside the parent list item.
CHANGELOG_BULLETS=""
if [ -n "$BREAKING_LINES" ]; then
CHANGELOG_BULLETS=$(printf ' **⚠️ Breaking**\n%s' "$BREAKING_LINES")
fi
if [ -n "$FEATURE_LINES" ]; then
block=$(printf ' **Features**\n%s' "$FEATURE_LINES")
if [ -n "$CHANGELOG_BULLETS" ]; then
CHANGELOG_BULLETS=$(printf '%s\n\n%s' "$CHANGELOG_BULLETS" "$block")
else
CHANGELOG_BULLETS="$block"
fi
fi
if [ -n "$FIX_LINES" ]; then
block=$(printf ' **Fixes**\n%s' "$FIX_LINES")
if [ -n "$CHANGELOG_BULLETS" ]; then
CHANGELOG_BULLETS=$(printf '%s\n\n%s' "$CHANGELOG_BULLETS" "$block")
else
CHANGELOG_BULLETS="$block"
fi
fi
{
echo "rollup_type=$ROLLUP_TYPE"
echo "rollup_bang=$ROLLUP_BANG"
echo "rollup_summary=$ROLLUP_SUMMARY"
echo "override_block<<OVERRIDE_EOF"
printf '%s\n' "$OVERRIDE_BLOCK"
echo "OVERRIDE_EOF"
echo "descriptions<<DESCRIPTIONS_EOF"
printf '%s\n' "$DESCRIPTIONS"
echo "DESCRIPTIONS_EOF"
echo "changelog_bullets<<BULLETS_EOF"
printf '%s\n' "$CHANGELOG_BULLETS"
echo "BULLETS_EOF"
echo "entries_json<<ENTRIES_EOF"
printf '%s\n' "${ENTRIES:-[]}"
echo "ENTRIES_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create batch PR on live SDK
id: batch-pr
if: success() && inputs.dry_run != true
working-directory: ${{ env.SDK_CHECKOUT_PATH }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
DESCRIPTIONS: ${{ steps.classify.outputs.descriptions }}
OVERRIDE_BLOCK: ${{ steps.classify.outputs.override_block }}
ENTRIES_JSON: ${{ steps.classify.outputs.entries_json }}
run: |
# Branch / commit / push / PR logic now lives in
# scripts/open-batch-pr.mjs (unit-tested locally). This step only
# prepares the bot identity, the .last-synced-sha marker, the classify
# entries (for per-entry commits) and the composed PR body, then
# delegates. The branch is the deterministic oagen/batch-<batch_id>,
# force-pushed so a re-dispatch with the same batch_id updates the one
# PR instead of opening a second.
git config user.name "workos-sdk-automation[bot]"
git config user.email "255426317+workos-sdk-automation[bot]@users.noreply.github.com"
cp "$GITHUB_WORKSPACE/openapi-spec/.last-synced-sha" .last-synced-sha
printf '%s' "${ENTRIES_JSON:-[]}" > /tmp/batch-entries.json
# BEGIN_COMMIT_OVERRIDE tells release-please to treat this
# squash-merged PR as one conventional commit per service;
# open-batch-pr.mjs appends the (#NN) refs after the PR is created.
{
echo "## Summary"
echo
echo "$DESCRIPTIONS"
echo
echo "Triggered by workos/openapi-spec@${{ github.sha }}"
if [ -n "$OVERRIDE_BLOCK" ]; then
echo
echo "$OVERRIDE_BLOCK"
fi
} > /tmp/pr-body.md
node "$GITHUB_WORKSPACE/openapi-spec/scripts/open-batch-pr.mjs" \
--batch-id "${{ inputs.batch_id }}" \
--lang "${{ matrix.language }}" \
--services "${{ inputs.services }}" \
--sdk-dir "$GITHUB_WORKSPACE/${{ env.SDK_CHECKOUT_PATH }}" \
--entries-file /tmp/batch-entries.json \
--body-file /tmp/pr-body.md
- name: Add release-notes fragment
if: >-
success() && inputs.dry_run != true &&
steps.batch-pr.outputs.skipped == '' &&
(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 }}
run: |
# Write a changelog fragment into the SDK repo on the batch branch.
# The SDK's release workflow (pilot rollout pending) concatenates all
# .changelog-pending/*.md under a new version heading in CHANGELOG.md
# 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
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"
# force-with-lease: this commit sits atop the branch open-batch-pr.mjs
# just force-pushed; the lease rejects (rather than clobbers) the push
# if a racing same-batch_id re-dispatch moved the remote in between.
git push --force-with-lease origin "$BRANCH"
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: oagen-diagnostics-${{ matrix.language }}
path: |
openapi-spec/.oagen/${{ matrix.language }}/
/tmp/diff-report.json
retention-days: 14
# Preview only: run the SDK's own CI (build + tests) on the freshly
# generated code, so the dashboard's per-language "green" means "generates
# AND builds/tests pass" rather than just "generation didn't error" (the
# same script/ci validate-sdks.yml runs on real PRs). Runs LAST so the diff
# + diagnostics are already captured/uploaded above regardless of the CI
# result — a CI failure reds this language's job (→ ❌ in the preview) but
# never blocks the diff report (preview_diff_report is always()).
- name: Run SDK CI (preview)
if: success() && inputs.dry_run == true
working-directory: ${{ env.SDK_CHECKOUT_PATH }}
run: |
if [ -x script/ci ]; then
script/ci
elif [ -x scripts/ci ]; then
scripts/ci
else
echo "No script/ci or scripts/ci found, skipping"
fi
# Preview only (dry_run). Aggregate every language's captured sdk-code.diff into
# one rendered HTML report (same builder validate-sdks.yml uses) and publish it
# to GitHub Pages under preview-<batch_id>/, so the SDK bot dashboard can link
# the actual file-level changes for a staged subset without opening any PR.
# always() so a partial-failure run still publishes the languages that worked.
preview_diff_report:
name: preview_diff_report
needs: [load-matrix, generate_pr]
if: ${{ always() && inputs.dry_run == true }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout openapi-spec
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
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: Download diagnostics
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: oagen-diagnostics-*
path: sdk-diagnostics
- name: Build code diff report
id: diff-report
run: |
languages="$(jq -r 'map(.language) | join(",")' .github/sdk-matrix.json)"
# generate_pr uploads the diagnostics artifact with a mixed path set
# (.oagen/<lang>/ + /tmp/diff-report.json), so upload-artifact roots it
# at "/" and sdk-code.diff lands under a deep home/runner/... prefix
# instead of at the artifact root build-sdk-diff-report expects. Hoist
# each language's sdk-code.diff back to oagen-diagnostics-<lang>/ so the
# builder (written for validate-sdks' single-path layout) finds it.
for lang in $(jq -r '.[].language' .github/sdk-matrix.json); do
dir="sdk-diagnostics/oagen-diagnostics-$lang"
[ -d "$dir" ] || continue
found="$(find "$dir" -name sdk-code.diff -print -quit 2>/dev/null || true)"
if [ -n "$found" ] && [ "$found" != "$dir/sdk-code.diff" ]; then
cp "$found" "$dir/sdk-code.diff"
fi
done
# --staged groups each language's files into "your staged service(s)"
# vs "other" (shared types/fixtures + already-on-disk services re-synced
# to the current spec), so the report separates what you staged from the
# rest. Empty (full preview) → no grouping.
node scripts/build-sdk-diff-report.mjs \
--artifacts-root sdk-diagnostics \
--languages "$languages" \
--staged "${{ inputs.services }}" \
--output /tmp/sdk-diff-report.html
- name: Upload code diff report
if: steps.diff-report.outputs.report-exists == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sdk-code-diff-report
path: /tmp/sdk-diff-report.html
retention-days: 14
- name: Publish preview to GitHub Pages
if: steps.diff-report.outputs.report-exists == 'true'
env:
PREVIEW_ID: ${{ inputs.batch_id }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
worktree=$(mktemp -d)
if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
git fetch --depth=1 origin gh-pages
git worktree add "$worktree" origin/gh-pages
(cd "$worktree" && git checkout -B gh-pages)
else
git worktree add --orphan -b gh-pages "$worktree"
: > "$worktree/.nojekyll"
fi
# batch_id already carries the "preview-" prefix, so the dir is
# preview-<uuid>/ (never collides with validate-sdks' pr-<N>/ dirs).
target_dir="$worktree/${PREVIEW_ID}"
mkdir -p "$target_dir"
cp /tmp/sdk-diff-report.html "$target_dir/sdk-diff-report.html"
# A creation marker survives the shallow gh-pages checkout (git history
# is depth-1, so per-dir commit dates are unusable) — prune reads it.
date +%s > "$target_dir/.created"
(
cd "$worktree"
# Prune preview-*/ dirs older than 14 days so on-demand previews don't
# accumulate unbounded (unlike pr-<N>/, they have no PR-close cleanup).
now=$(date +%s)
for marker in preview-*/.created; do
[ -f "$marker" ] || continue
d="$(dirname "$marker")"
ts="$(cat "$marker" 2>/dev/null || echo "$now")"
case "$ts" in ''|*[!0-9]*) ts="$now" ;; esac
if [ "$(( (now - ts) / 86400 ))" -gt 14 ]; then
git rm -r --quiet "$d" || true
fi
done
git add -A
if git diff --cached --quiet; then
echo "No changes to publish"
else
git commit -m "SDK diff preview ${PREVIEW_ID}"
git push origin gh-pages
fi
)
owner="${REPO%%/*}"
name="${REPO#*/}"
echo "Preview: https://${owner}.github.io/${name}/${PREVIEW_ID}/sdk-diff-report.html"