Generate PRs (batch preview-fa077002) #114
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |