diff --git a/.github/scripts/openapi-breaking-changes.py b/.github/scripts/openapi-breaking-changes.py new file mode 100644 index 0000000000..097e8d4577 --- /dev/null +++ b/.github/scripts/openapi-breaking-changes.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Detect breaking changes between two OpenAPI description files. + +Implements the project's documented breaking-change list: + + 1. An operationId name has been changed. + 2. A URL parameter name has been changed. + 3. An operation has been removed from the description. + 4. A `required: true` has been added to the requestBody. + 5. A parameter has been added to the required list for a requestBody. + 6. A field has been removed from a response body. + 7. A field type has changed in a response body. + 8. A field has been removed from the required list in a response body. + +Plus two structural cases that make the above unrepresentable: + 9. A schema definition has been removed from components/schemas. + 10. An enum value has been removed. + +Note the asymmetry that line-based diffing gets wrong: for a *request* body, +*adding* to `required` is breaking; for a *response* body, *removing* from +`required` is breaking. Both are additive/subtractive in opposite directions, +so they cannot be detected by scanning removed diff lines alone. + +Exit status is always 0; findings go to stdout as JSON. The caller decides +policy. +""" + +import json +import sys + +import yaml + +try: + from yaml import CSafeLoader as Loader +except ImportError: # pragma: no cover - libyaml is present on ubuntu-latest + from yaml import SafeLoader as Loader + +METHODS = ('get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace') + + +def load(path): + with open(path, encoding='utf-8') as handle: + return yaml.load(handle, Loader=Loader) + + +def operations(doc): + """Map (path, method) -> operation object.""" + out = {} + for path, item in (doc.get('paths') or {}).items(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method in METHODS and isinstance(op, dict): + out[(path, method)] = op + return out + + +def path_params(op): + return { + p.get('name') + for p in (op.get('parameters') or []) + if isinstance(p, dict) and p.get('in') == 'path' + } + + +def request_body(op): + body = op.get('requestBody') + return body if isinstance(body, dict) else {} + + +def json_schema(container): + """Pull the application/json schema out of a requestBody/response.""" + content = container.get('content') + if not isinstance(content, dict): + return {} + for media, spec in content.items(): + if 'json' in media and isinstance(spec, dict): + schema = spec.get('schema') + return schema if isinstance(schema, dict) else {} + return {} + + +def enum_values(schema): + values = schema.get('enum') + return set(map(str, values)) if isinstance(values, list) else set() + + +def compare_operations(base, head, findings): + base_ops, head_ops = operations(base), operations(head) + + for key, op in base_ops.items(): + path, method = key + label = f'{method.upper()} {path}' + + if key not in head_ops: + findings.append({ + 'rule': 'operation-removed', + 'detail': f'{label} was removed', + }) + continue + + new = head_ops[key] + + old_id, new_id = op.get('operationId'), new.get('operationId') + if old_id and new_id and old_id != new_id: + findings.append({ + 'rule': 'operationid-changed', + 'detail': f'{label}: operationId {old_id!r} -> {new_id!r}', + }) + + removed_params = path_params(op) - path_params(new) + if removed_params: + findings.append({ + 'rule': 'url-parameter-renamed', + 'detail': f'{label}: path parameter(s) gone: {sorted(removed_params)}', + }) + + old_body, new_body = request_body(op), request_body(new) + if new_body.get('required') and not old_body.get('required'): + findings.append({ + 'rule': 'requestbody-now-required', + 'detail': f'{label}: requestBody became required', + }) + + added_required = set(json_schema(new_body).get('required') or []) - \ + set(json_schema(old_body).get('required') or []) + if added_required: + findings.append({ + 'rule': 'requestbody-required-added', + 'detail': f'{label}: new required request field(s): {sorted(added_required)}', + }) + + +def compare_schemas(base, head, findings): + """Compare components/schemas. + + Responses overwhelmingly `$ref` into components/schemas in the + non-dereferenced description, so comparing schemas covers "field removed + from a response body" and "field type changed" without resolving refs. + """ + base_schemas = (base.get('components') or {}).get('schemas') or {} + head_schemas = (head.get('components') or {}).get('schemas') or {} + + for name, old in base_schemas.items(): + if not isinstance(old, dict): + continue + + new = head_schemas.get(name) + if new is None: + findings.append({ + 'rule': 'schema-removed', + 'detail': f'schema {name!r} was removed', + }) + continue + if not isinstance(new, dict): + continue + + old_props = old.get('properties') or {} + new_props = new.get('properties') or {} + + for prop in set(old_props) - set(new_props): + findings.append({ + 'rule': 'response-field-removed', + 'detail': f'{name}.{prop} was removed', + }) + + for prop in set(old_props) & set(new_props): + old_p, new_p = old_props[prop], new_props[prop] + if not isinstance(old_p, dict) or not isinstance(new_p, dict): + continue + + old_t, new_t = old_p.get('type'), new_p.get('type') + if old_t and new_t and old_t != new_t: + findings.append({ + 'rule': 'response-field-type-changed', + 'detail': f'{name}.{prop}: type {old_t!r} -> {new_t!r}', + }) + + dropped = enum_values(old_p) - enum_values(new_p) + if dropped: + findings.append({ + 'rule': 'enum-value-removed', + 'detail': f'{name}.{prop}: enum value(s) removed: {sorted(dropped)}', + }) + + # For a response body, losing a guarantee is the breaking direction. + dropped_required = set(old.get('required') or []) - set(new.get('required') or []) + if dropped_required: + findings.append({ + 'rule': 'response-required-removed', + 'detail': f'{name}: no longer guaranteed: {sorted(dropped_required)}', + }) + + dropped = enum_values(old) - enum_values(new) + if dropped: + findings.append({ + 'rule': 'enum-value-removed', + 'detail': f'schema {name}: enum value(s) removed: {sorted(dropped)}', + }) + + +def summarize(base, head): + """Non-breaking additions, used for the informational PR summary.""" + base_ops, head_ops = operations(base), operations(head) + added = [f'{m.upper()} {p}' for (p, m) in set(head_ops) - set(base_ops)] + + base_schemas = set((base.get('components') or {}).get('schemas') or {}) + head_schemas = set((head.get('components') or {}).get('schemas') or {}) + + return { + 'added_operations': sorted(added), + 'added_schemas': sorted(head_schemas - base_schemas), + 'total_operations': len(head_ops), + 'total_schemas': len(head_schemas), + } + + +def main(): + if len(sys.argv) != 3: + print('usage: openapi-breaking-changes.py BASE.yaml HEAD.yaml', file=sys.stderr) + return 2 + + base, head = load(sys.argv[1]), load(sys.argv[2]) + + findings = [] + compare_operations(base, head, findings) + compare_schemas(base, head, findings) + + json.dump({'findings': findings, 'summary': summarize(base, head)}, sys.stdout, indent=2) + print() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml new file mode 100644 index 0000000000..7dafc61577 --- /dev/null +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -0,0 +1,388 @@ +name: Auto-merge OpenAPI description updates + +# Merges the newest open `github-openapi-bot` "Update OpenAPI 3.x Descriptions" +# PRs and closes the older superseded ones. +# +# Why a workflow instead of native auto-merge / the merge API: these PRs carry +# 100K+ line diffs across 64+ files. `PUT /pulls/{n}/merge` returns 502/504 and +# `GET /pulls/{n}/files` returns 422 on them, so the merge is done with plain +# git against a blobless clone. +# +# This runs continuously. The safety properties of the manual process are +# preserved: lint must be green, the semantic breaking-change scan must come +# back clean, and a change summary is posted to the PR before merge. +# +# Merges are additionally held during a GHES release-candidate window. See the +# "Check for an active merge freeze" step for how that is detected. + +on: + schedule: + - cron: '17 */2 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Analyze and report, but do not merge or close anything' + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +concurrency: + group: auto-merge-openapi-updates + cancel-in-progress: false + +env: + BOT_LOGIN: github-openapi-bot + # Status checks that must be green before merging. CodeQL is deliberately + # excluded: default setup only scans the `actions` language, it is not a + # required check on main, and it routinely reports `timed_out` on these PRs. + REQUIRED_CHECKS: 'Lint OpenAPI 3.0 releases,Lint OpenAPI 3.1 releases' + +jobs: + auto-merge: + name: Auto-merge OpenAPI updates + runs-on: ubuntu-latest + outputs: + status: ${{ steps.merge.outputs.status }} + detail: ${{ steps.merge.outputs.detail }} + steps: + - name: Select candidate PRs + id: select + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + open_prs=$(gh pr list \ + --state open \ + --author "$BOT_LOGIN" \ + --limit 100 \ + --json number,title,headRefName,headRefOid,createdAt) + + select_newest() { + jq -r --arg t "$1" \ + '[.[] | select(.title == $t)] | sort_by(.createdAt) | last // empty' <<<"$open_prs" + } + + newest_30=$(select_newest 'Update OpenAPI 3.0 Descriptions') + newest_31=$(select_newest 'Update OpenAPI 3.1 Descriptions') + newest_30=${newest_30:-null} + newest_31=${newest_31:-null} + + pr_30=$(jq -r '.number // empty' <<<"$newest_30") + pr_31=$(jq -r '.number // empty' <<<"$newest_31") + + if [ -z "$pr_30" ] && [ -z "$pr_31" ]; then + echo "No open $BOT_LOGIN description PRs. Nothing to do." + echo "found=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "found=true" + echo "pr_30=$pr_30" + echo "pr_31=$pr_31" + echo "ref_30=$(jq -r '.headRefName // empty' <<<"$newest_30")" + echo "ref_31=$(jq -r '.headRefName // empty' <<<"$newest_31")" + } >>"$GITHUB_OUTPUT" + + # Every open bot PR that is not one of the two selected is superseded. + superseded=$(jq -r \ + --argjson keep30 "${pr_30:-0}" \ + --argjson keep31 "${pr_31:-0}" \ + '[.[].number | select(. != $keep30 and . != $keep31)] | join(" ")' \ + <<<"$open_prs") + echo "superseded=$superseded" >>"$GITHUB_OUTPUT" + + echo "3.0 PR: ${pr_30:-none} / 3.1 PR: ${pr_31:-none}" + echo "Superseded: ${superseded:-none}" + + - name: Verify required checks are green + id: checks + if: steps.select.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_30: ${{ steps.select.outputs.pr_30 }} + PR_31: ${{ steps.select.outputs.pr_31 }} + run: | + set -euo pipefail + + blocked='' + for pr in $PR_30 $PR_31; do + [ -n "$pr" ] || continue + sha=$(gh pr view "$pr" --json headRefOid -q .headRefOid) + runs=$(gh api "repos/$GH_REPO/commits/$sha/check-runs" --paginate \ + -q '.check_runs[] | "\(.name)\t\(.status)\t\(.conclusion)"') + + IFS=',' read -ra required <<<"$REQUIRED_CHECKS" + for name in "${required[@]}"; do + matches=$(awk -F'\t' -v n="$name" '$1 == n' <<<"$runs") + if [ -z "$matches" ]; then + blocked+="PR #$pr: required check '$name' has not reported. " + continue + fi + if grep -qv $'\tcompleted\tsuccess$' <<<"$matches"; then + blocked+="PR #$pr: check '$name' is not passing. " + fi + done + done + + if [ -n "$blocked" ]; then + echo "status=blocked" >>"$GITHUB_OUTPUT" + echo "detail=$blocked" >>"$GITHUB_OUTPUT" + echo "::notice::$blocked" + else + echo "status=green" >>"$GITHUB_OUTPUT" + fi + + - name: Check for an active merge freeze + id: freeze + if: steps.select.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + REF_30: ${{ steps.select.outputs.ref_30 }} + REF_31: ${{ steps.select.outputs.ref_31 }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # Around a GHES release candidate, hold description merges until the + # coordinated release content is ready. A new GHES version appearing + # in the candidate PR before it exists on the default branch is the + # public signal that this window may be active. + + reason='' + + # --- Signal 1: an explicit, human-controlled hold ---------------- + # A label is the escape hatch that does not depend on inference, and + # lets Docs or the FR stop merges for any reason at all. + if gh label list --search 'merge-freeze' --json name -q '.[].name' | grep -qx 'merge-freeze'; then + frozen=$(gh issue list --label 'merge-freeze' --state open --json number,title \ + -q '.[] | "#\(.number) \(.title)"' | head -5) + if [ -n "$frozen" ]; then + reason+="Open 'merge-freeze' issue(s): $(tr '\n' ';' <<<"$frozen") " + fi + fi + + # --- Signal 2: a GHES version not yet on the default branch ------ + ghes_on_main=$(gh api "repos/$GH_REPO/contents/descriptions?ref=$DEFAULT_BRANCH" \ + -q '.[].name' | grep '^ghes-' | sort -u) + + for ref in "$REF_30" "$REF_31"; do + [ -n "$ref" ] || continue + for dir in descriptions descriptions-next; do + on_pr=$(gh api "repos/$GH_REPO/contents/$dir?ref=$ref" \ + -q '.[].name' 2>/dev/null | grep '^ghes-' | sort -u || true) + [ -n "$on_pr" ] || continue + new=$(comm -13 <(echo "$ghes_on_main") <(echo "$on_pr") | tr '\n' ' ') + if [ -n "${new// /}" ]; then + reason+="$ref/$dir introduces new GHES version(s): $new " + fi + done + done + + if [ -n "$reason" ]; then + echo "status=frozen" >>"$GITHUB_OUTPUT" + echo "detail=$reason" >>"$GITHUB_OUTPUT" + echo "::warning::Merge freeze in effect, holding. $reason" + else + echo "status=clear" >>"$GITHUB_OUTPUT" + echo "No merge freeze detected." + fi + + - name: Checkout (blobless) + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' + uses: actions/checkout@v4 + with: + # Blobless fetch keeps this off the ~4.6 GB full history while still + # allowing real merges. Blobs for the touched files are fetched lazily. + filter: blob:none + fetch-depth: 0 + token: ${{ github.token }} + + - name: Set up Python + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PyYAML + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' + # Reuses the repo's existing pinned/hash-verified requirements, which + # already provide pyyaml for the linter workflow. + run: pip install --require-hashes -r requirements.txt + + - name: Scan for breaking changes + id: breaking + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' + env: + REF_30: ${{ steps.select.outputs.ref_30 }} + REF_31: ${{ steps.select.outputs.ref_31 }} + PR_30: ${{ steps.select.outputs.pr_30 }} + PR_31: ${{ steps.select.outputs.pr_31 }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # A semantic (parsed) comparison rather than a diff scan. The two are + # not equivalent: for a *request* body, ADDING to `required` is + # breaking, while for a *response* body, REMOVING from `required` is + # breaking. A scan of removed diff lines is structurally blind to the + # first case. Rules mirror the breaking-change list in + # the project's documented release-compatibility rules. + findings='' + : >summary.md + + for pair in \ + "$REF_30|descriptions/api.github.com/api.github.com.yaml|3.0|$PR_30" \ + "$REF_31|descriptions-next/api.github.com/api.github.com.yaml|3.1|$PR_31"; do + IFS='|' read -r ref file label pr <<<"$pair" + [ -n "$ref" ] || continue + + git fetch --no-tags --filter=blob:none origin "$ref":"refs/remotes/origin/$ref" + + # api.github.com is the non-dereferenced source of truth: compact, + # $ref-based, and every platform variant derives from it. + git show "origin/$DEFAULT_BRANCH:$file" >base.yaml + git show "origin/$ref:$file" >head.yaml + + python3 .github/scripts/openapi-breaking-changes.py base.yaml head.yaml >result.json + + count=$(jq '.findings | length' result.json) + { + echo "### OpenAPI $label (#$pr)" + echo + jq -r '.summary | + "- Operations: \(.total_operations) total, \(.added_operations | length) added", + "- Schemas: \(.total_schemas) total, \(.added_schemas | length) added"' result.json + jq -r '.summary.added_operations[]? | " - `\(.)`"' result.json | head -40 + echo + } >>summary.md + + if [ "$count" -gt 0 ]; then + findings+="$label (#$pr): $count breaking finding(s). " + { + echo "#### :rotating_light: Breaking changes in $label" + echo + jq -r '.findings[] | "- **\(.rule)**: \(.detail)"' result.json | head -50 + echo + } >>summary.md + fi + done + + cat summary.md + + if [ -n "$findings" ]; then + echo "status=breaking" >>"$GITHUB_OUTPUT" + echo "detail=$findings" >>"$GITHUB_OUTPUT" + echo "::warning::Potential breaking changes, skipping auto-merge. $findings" + else + echo "status=clean" >>"$GITHUB_OUTPUT" + fi + + - name: Post change summary to PRs + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_30: ${{ steps.select.outputs.pr_30 }} + PR_31: ${{ steps.select.outputs.pr_31 }} + BREAKING: ${{ steps.breaking.outputs.status }} + run: | + set -euo pipefail + + # Release notes are generated from PR descriptions. Auto-merging + # untouched bodies would remove useful context, so post the analysis + # the scanner already computed. + { + if [ "$BREAKING" = 'breaking' ]; then + echo "## :rotating_light: Auto-merge skipped: potential breaking changes" + else + echo "## Automated change summary" + fi + echo + cat summary.md + echo "_Posted by the auto-merge workflow ([run](${GITHUB_SERVER_URL}/${GH_REPO}/actions/runs/${GITHUB_RUN_ID}))._" + } >comment.md + + for pr in $PR_30 $PR_31; do + [ -n "$pr" ] || continue + gh pr comment "$pr" --body-file comment.md || echo "::warning::Could not comment on #$pr" + done + + - name: Merge and close superseded PRs + id: merge + if: >- + steps.checks.outputs.status == 'green' && + steps.freeze.outputs.status == 'clear' && + steps.breaking.outputs.status == 'clean' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_30: ${{ steps.select.outputs.pr_30 }} + PR_31: ${{ steps.select.outputs.pr_31 }} + REF_30: ${{ steps.select.outputs.ref_30 }} + REF_31: ${{ steps.select.outputs.ref_31 }} + SUPERSEDED: ${{ steps.select.outputs.superseded }} + DRY_RUN: ${{ inputs.dry_run }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: would merge PRs ${PR_30:-none} and ${PR_31:-none}, close: ${SUPERSEDED:-none}" + echo "status=dry-run" >>"$GITHUB_OUTPUT" + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout "$DEFAULT_BRANCH" + + merged='' + # 3.0 first, then 3.1: they touch disjoint trees but this ordering + # keeps the generated merge history readable. + for pair in "$PR_30:$REF_30" "$PR_31:$REF_31"; do + pr="${pair%%:*}"; ref="${pair#*:}" + [ -n "$pr" ] && [ -n "$ref" ] || continue + git fetch --no-tags origin "$ref":"refs/remotes/origin/$ref" --filter=blob:none + git merge --no-ff "origin/$ref" -m "Merge pull request #$pr from $ref" + merged+="#$pr " + done + + if [ -n "$merged" ]; then + git push origin "$DEFAULT_BRANCH" + echo "Merged and pushed: $merged" + fi + + for pr in $SUPERSEDED; do + gh pr close "$pr" \ + --comment "Superseded by the newer OpenAPI description update(s) ${merged:-just merged}. Closing automatically." \ + || echo "::warning::Failed to close #$pr" + done + + echo "status=merged" >>"$GITHUB_OUTPUT" + echo "detail=Merged $merged" >>"$GITHUB_OUTPUT" + + - name: Summarize exceptions + if: >- + failure() || + steps.breaking.outputs.status == 'breaking' || + steps.freeze.outputs.status == 'frozen' + run: | + { + echo "## Auto-merge did not proceed" + echo + if [ "${{ steps.freeze.outputs.status }}" = "frozen" ]; then + echo "A merge freeze was detected: ${{ steps.freeze.outputs.detail }}" + elif [ "${{ steps.breaking.outputs.status }}" = "breaking" ]; then + echo "Potential breaking changes were detected: ${{ steps.breaking.outputs.detail }}" + else + echo "The workflow failed before merging. Review the failed step above." + fi + } >>"$GITHUB_STEP_SUMMARY"