From bd6a129c09953767ad15f6d0aeba8790267623b1 Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 09:44:50 -0500 Subject: [PATCH 1/7] Add auto-merge workflow for OpenAPI description update PRs --- .../workflows/auto-merge-openapi-updates.yml | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 .github/workflows/auto-merge-openapi-updates.yml diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml new file mode 100644 index 0000000000..d8597d536f --- /dev/null +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -0,0 +1,281 @@ +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. + +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: Checkout (blobless) + if: steps.checks.outputs.status == 'green' + 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: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + + - name: Scan for breaking changes + id: breaking + if: steps.checks.outputs.status == 'green' + env: + REF_30: ${{ steps.select.outputs.ref_30 }} + REF_31: ${{ steps.select.outputs.ref_31 }} + run: | + set -euo pipefail + + base="origin/${{ github.event.repository.default_branch }}" + + # api.github.com is the non-dereferenced source of truth: it is compact, + # uses $ref, and every other platform file derives from the same change. + findings='' + for pair in \ + "$REF_30:descriptions/api.github.com/api.github.com.yaml" \ + "$REF_31:descriptions-next/api.github.com/api.github.com.yaml"; do + ref="${pair%%:*}"; file="${pair#*:}" + [ -n "$ref" ] || continue + + git fetch --no-tags --filter=blob:none origin "$ref":"refs/remotes/origin/$ref" + diff=$(git diff "$base...origin/$ref" -- "$file" || true) + [ -n "$diff" ] || continue + + removed=$(grep '^-' <<<"$diff" | grep -v '^---' || true) + [ -n "$removed" ] || continue + + count() { grep -cE "$1" <<<"$removed" || true; } + # Removed top-level path key, e.g. ` "/repos/{owner}/{repo}":` + paths=$(count '^- "/') + # Removed enum member, e.g. ` - archived` + enums=$(count '^-[[:space:]]+- [A-Za-z0-9_.-]+$') + # Removed schema definition under components/schemas + schemas=$(count '^- [a-z0-9][a-z0-9-]*:$') + + if [ "${paths:-0}" -gt 0 ]; then findings+="$ref: $paths removed path key(s). "; fi + if [ "${enums:-0}" -gt 0 ]; then findings+="$ref: $enums removed enum value(s). "; fi + if [ "${schemas:-0}" -gt 0 ]; then findings+="$ref: $schemas removed schema key(s). "; fi + done + + 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: Merge and close superseded PRs + id: merge + if: steps.checks.outputs.status == 'green' && steps.breaking.outputs.status == 'clean' + env: + GH_TOKEN: ${{ secrets.OPENAPI_MERGE_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 + # matches the manual runbook and keeps 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: Notify #api-platform + if: >- + failure() || + steps.breaking.outputs.status == 'breaking' + env: + CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} + CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_30: ${{ steps.select.outputs.pr_30 }} + PR_31: ${{ steps.select.outputs.pr_31 }} + BREAKING: ${{ steps.breaking.outputs.detail }} + run: | + set -euo pipefail + + if [ -z "${CHATTERBOX_URL:-}" ] || [ -z "${CHATTERBOX_TOKEN:-}" ]; then + echo "Chatterbox not configured; skipping notification." + exit 0 + fi + + if [ -n "${BREAKING:-}" ]; then + headline=":rotating_light: OpenAPI auto-merge skipped: potential breaking changes need a human." + body="• Findings: ${BREAKING}" + else + headline=":warning: OpenAPI auto-merge failed in ${GITHUB_REPOSITORY}." + body="• Needs manual merge" + fi + + message=$(printf '%s\n' \ + "$headline" \ + "• PRs: #${PR_30:-n/a} (3.0), #${PR_31:-n/a} (3.1)" \ + "$body" \ + "• Run: ${RUN_URL}") + + curl --fail --silent --show-error \ + -X POST \ + -u "${CHATTERBOX_TOKEN}:" \ + "${CHATTERBOX_URL%/}/topics/%23api-platform" \ + --data "$message" From 1b17c1806c35cece318c0d870fe58558f94199d4 Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 10:28:04 -0500 Subject: [PATCH 2/7] Hard-fail preflight for merge token and chatterbox route --- .../workflows/auto-merge-openapi-updates.yml | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml index d8597d536f..6e86b662ed 100644 --- a/.github/workflows/auto-merge-openapi-updates.yml +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -17,6 +17,10 @@ on: description: 'Analyze and report, but do not merge or close anything' type: boolean default: false + test_notify: + description: 'Send a test post to #api-platform to prove the chatterbox route works' + type: boolean + default: false permissions: contents: write @@ -41,8 +45,87 @@ jobs: status: ${{ steps.merge.outputs.status }} detail: ${{ steps.merge.outputs.detail }} steps: + - name: Preflight - verify credentials + id: preflight + env: + MERGE_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN }} + CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} + CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} + GH_REPO: ${{ github.repository }} + TEST_NOTIFY: ${{ inputs.test_notify }} + run: | + set -euo pipefail + + fail='' + + # --- Merge token --------------------------------------------------- + # Falling back to github.token is fine, but a token that is *set* and + # broken (expired PAT, revoked, wrong scopes) must fail loudly here + # rather than as an opaque `git push` rejection after the merge. + if [ -z "${MERGE_TOKEN:-}" ]; then + echo "::warning::OPENAPI_MERGE_TOKEN is not set; falling back to GITHUB_TOKEN. Pushes will not trigger downstream workflows." + else + if ! login=$(GH_TOKEN="$MERGE_TOKEN" gh api user -q '.login' 2>/dev/null); then + # Fine-grained tokens and app installation tokens cannot call + # /user, so only treat this as fatal if the repo probe also fails. + login='(unknown; /user not available for this token type)' + fi + + # `gh api` writes its error body to stdout, so a `|| echo ERROR` + # sentinel would be appended to that body rather than replacing it. + # Branch on the exit status instead. + if perms=$(GH_TOKEN="$MERGE_TOKEN" gh api "repos/$GH_REPO" \ + -q '"\(.permissions.push)\t\(.permissions.admin)"' 2>/dev/null); then + push=${perms%%$'\t'*} + if [ "$push" != 'true' ]; then + fail+="OPENAPI_MERGE_TOKEN cannot push to $GH_REPO (contents:write missing; permissions.push=$push). " + else + echo "Merge token OK. Identity: $login, push: $push" + fi + else + fail+="OPENAPI_MERGE_TOKEN is set but cannot read $GH_REPO (expired, revoked, or lacking repo access). " + fi + fi + + # --- Chatterbox ---------------------------------------------------- + # The notifier is the only signal that a breaking change was skipped, + # so a silently-dead route would make the guard useless. + if [ -z "${CHATTERBOX_URL:-}" ] || [ -z "${CHATTERBOX_TOKEN:-}" ]; then + fail+="CHATTERBOX_URL/CHATTERBOX_TOKEN are not both set; breaking-change alerts would go nowhere. " + else + # curl already writes `000` on connection failure *and* exits + # non-zero, so a `|| echo 000` fallback would concatenate into + # `000000`. Swallow the exit status with `|| true` instead. + code=$(curl --silent --output /dev/null --write-out '%{http_code}' \ + --max-time 20 \ + -u "${CHATTERBOX_TOKEN}:" \ + "${CHATTERBOX_URL%/}/topics/%23api-platform" \ + --data ':white_check_mark: OpenAPI auto-merge preflight: chatterbox route is alive.' \ + || true) + + case "${code:-000}" in + 2*) echo "Chatterbox OK (HTTP $code)." ;; + 000|'') fail+="Chatterbox unreachable (connection failed or timed out). " ;; + 401|403) fail+="Chatterbox rejected CHATTERBOX_TOKEN (HTTP $code). " ;; + *) fail+="Chatterbox returned HTTP $code. " ;; + esac + fi + + if [ -n "$fail" ]; then + echo "::error::Preflight failed: $fail" + exit 1 + fi + + if [ "${TEST_NOTIFY:-false}" = 'true' ]; then + echo "test_notify requested; preflight post sent. Stopping before any merge." + echo "stop=true" >>"$GITHUB_OUTPUT" + fi + + echo "Preflight passed." + - name: Select candidate PRs id: select + if: steps.preflight.outputs.stop != 'true' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -243,8 +326,8 @@ jobs: - name: Notify #api-platform if: >- - failure() || - steps.breaking.outputs.status == 'breaking' + steps.preflight.outcome == 'success' && + (failure() || steps.breaking.outputs.status == 'breaking') env: CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} From 0453e20e49c4c8d9f5e67146226aca7093a6b341 Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 10:58:07 -0500 Subject: [PATCH 3/7] Add semantic OpenAPI breaking-change detector --- .github/scripts/openapi-breaking-changes.py | 236 ++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 .github/scripts/openapi-breaking-changes.py diff --git a/.github/scripts/openapi-breaking-changes.py b/.github/scripts/openapi-breaking-changes.py new file mode 100644 index 0000000000..1eb65f4673 --- /dev/null +++ b/.github/scripts/openapi-breaking-changes.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Detect breaking changes between two OpenAPI description files. + +Implements the breaking-change list from +`github/api-platform/docs/creating-an-openapi-release.md`: + + 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()) From 2c3eca70514e43f3fa76f7ba7156246077ce55a5 Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 10:58:13 -0500 Subject: [PATCH 4/7] Use semantic breaking-change scan and post change summary to PRs --- .../workflows/auto-merge-openapi-updates.yml | 118 ++++++++++++++---- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml index 6e86b662ed..f62035ff99 100644 --- a/.github/workflows/auto-merge-openapi-updates.yml +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -7,6 +7,13 @@ name: Auto-merge OpenAPI description updates # 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 automates step 6 of the API Platform first-responder runbook +# (`openapi-pr-reviewer`). The runbook runs it on Tuesdays and Thursdays; this +# runs continuously, so the PR backlog never accumulates. The safety properties +# the FR provides by hand are preserved: lint must be green, a semantic +# breaking-change scan must come back clean, and a change summary is posted to +# the PR before merge for release-note authoring. on: schedule: @@ -225,46 +232,78 @@ jobs: fetch-depth: 0 token: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + - name: Set up Python + if: steps.checks.outputs.status == 'green' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PyYAML + if: steps.checks.outputs.status == 'green' + # 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' 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 - base="origin/${{ github.event.repository.default_branch }}" - - # api.github.com is the non-dereferenced source of truth: it is compact, - # uses $ref, and every other platform file derives from the same change. + # 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 + # github/api-platform docs/creating-an-openapi-release.md. findings='' + : >summary.md + for pair in \ - "$REF_30:descriptions/api.github.com/api.github.com.yaml" \ - "$REF_31:descriptions-next/api.github.com/api.github.com.yaml"; do - ref="${pair%%:*}"; file="${pair#*:}" + "$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" - diff=$(git diff "$base...origin/$ref" -- "$file" || true) - [ -n "$diff" ] || continue - - removed=$(grep '^-' <<<"$diff" | grep -v '^---' || true) - [ -n "$removed" ] || continue - - count() { grep -cE "$1" <<<"$removed" || true; } - # Removed top-level path key, e.g. ` "/repos/{owner}/{repo}":` - paths=$(count '^- "/') - # Removed enum member, e.g. ` - archived` - enums=$(count '^-[[:space:]]+- [A-Za-z0-9_.-]+$') - # Removed schema definition under components/schemas - schemas=$(count '^- [a-z0-9][a-z0-9-]*:$') - - if [ "${paths:-0}" -gt 0 ]; then findings+="$ref: $paths removed path key(s). "; fi - if [ "${enums:-0}" -gt 0 ]; then findings+="$ref: $enums removed enum value(s). "; fi - if [ "${schemas:-0}" -gt 0 ]; then findings+="$ref: $schemas removed schema key(s). "; fi + + # 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" @@ -273,6 +312,37 @@ jobs: echo "status=clean" >>"$GITHUB_OUTPUT" fi + - name: Post change summary to PRs + if: steps.checks.outputs.status == 'green' + env: + GH_TOKEN: ${{ secrets.OPENAPI_MERGE_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, and the runbook + # asks for a human-written change summary before merge. Auto-merging + # untouched bodies would delete that input, 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.breaking.outputs.status == 'clean' From 46ef8afc29ffcccb9d29033b99ed0e1b9691de5e Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 11:43:48 -0500 Subject: [PATCH 5/7] Hold merges during GHES release-candidate freeze windows --- .../workflows/auto-merge-openapi-updates.yml | 131 ++++++++++++++++-- 1 file changed, 123 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml index f62035ff99..8144a7d6d8 100644 --- a/.github/workflows/auto-merge-openapi-updates.yml +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -14,6 +14,11 @@ name: Auto-merge OpenAPI description updates # the FR provides by hand are preserved: lint must be green, a semantic # breaking-change scan must come back clean, and a change summary is posted to # the PR before merge for release-note authoring. +# +# Merges are additionally held during a GHES release-candidate window, when +# Docs asks the team to stop merging description PRs until the corresponding +# RC PR lands in github/docs-internal. See the "Check for an active merge +# freeze" step for how that is detected and how it clears. on: schedule: @@ -222,8 +227,76 @@ jobs: 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, Docs asks the team to hold off + # merging description PRs. The sequence is: + # 1. github/github sets `published: true` for the new GHES version + # 2. Docs asks #api-platform to hold merges + # 3. Docs merges the RC PR in github/docs-internal + # 4. Docs gives the all-clear and merges resume + # + # Step 1 is what makes the new GHES version's descriptions appear in + # the bot's PR here, so "this PR introduces a GHES version that does + # not exist on the default branch" is a reliable proxy for being + # inside that window. It is also self-clearing: once the version is + # merged the directory exists on main and later PRs stop matching. + # + # The window is genuinely short (for 3.21, descriptions landed 18 + # minutes before the docs RC merged), so this must be checked per-run + # rather than assumed stale. + + 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' + 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 @@ -233,20 +306,20 @@ jobs: token: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} - name: Set up Python - if: steps.checks.outputs.status == 'green' + 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' + 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' + 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 }} @@ -313,7 +386,7 @@ jobs: fi - name: Post change summary to PRs - if: steps.checks.outputs.status == 'green' + if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' env: GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} GH_REPO: ${{ github.repository }} @@ -345,7 +418,10 @@ jobs: - name: Merge and close superseded PRs id: merge - if: steps.checks.outputs.status == 'green' && steps.breaking.outputs.status == 'clean' + if: >- + steps.checks.outputs.status == 'green' && + steps.freeze.outputs.status == 'clear' && + steps.breaking.outputs.status == 'clean' env: GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} GH_REPO: ${{ github.repository }} @@ -397,7 +473,9 @@ jobs: - name: Notify #api-platform if: >- steps.preflight.outcome == 'success' && - (failure() || steps.breaking.outputs.status == 'breaking') + (failure() || + steps.breaking.outputs.status == 'breaking' || + steps.freeze.outputs.status == 'frozen') env: CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} @@ -405,6 +483,9 @@ jobs: PR_30: ${{ steps.select.outputs.pr_30 }} PR_31: ${{ steps.select.outputs.pr_31 }} BREAKING: ${{ steps.breaking.outputs.detail }} + FREEZE: ${{ steps.freeze.outputs.detail }} + GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail @@ -413,7 +494,41 @@ jobs: exit 0 fi - if [ -n "${BREAKING:-}" ]; then + if [ -n "${FREEZE:-}" ]; then + # A freeze is an expected state, not a fault. It is re-detected + # every 2 hours for the whole GHES RC window, so announce it once + # per PR rather than pinging the channel dozens of times. The PR + # comment is the durable marker; a workspace file would not survive + # between runs. + already='' + for pr in $PR_30 $PR_31; do + [ -n "$pr" ] || continue + if gh pr view "$pr" --json comments \ + -q '.comments[].body' 2>/dev/null | grep -q 'AUTO-MERGE-FREEZE-NOTICE'; then + already='yes' + fi + done + + if [ -n "$already" ]; then + echo "Freeze already announced for these PRs; not re-posting." + exit 0 + fi + + for pr in $PR_30 $PR_31; do + [ -n "$pr" ] || continue + gh pr comment "$pr" --body "$(printf '%s\n' \ + '## :snowflake: Auto-merge is holding (merge freeze)' \ + '' \ + "Reason: ${FREEZE}" \ + '' \ + 'This is expected around a GHES release candidate, while Docs merges the corresponding RC PR in `github/docs-internal`. Merges resume automatically once the new GHES version is present on the default branch and any open `merge-freeze` issue is closed. No action needed unless this persists past the all-clear.' \ + '' \ + '')" || true + done + + headline=":snowflake: OpenAPI auto-merge is holding: merge freeze detected." + body="• Reason: ${FREEZE}"$'\n'"• Merges resume automatically once the new GHES version is on the default branch and any 'merge-freeze' issue is closed." + elif [ -n "${BREAKING:-}" ]; then headline=":rotating_light: OpenAPI auto-merge skipped: potential breaking changes need a human." body="• Findings: ${BREAKING}" else From 0d9bf49fac80eb56b7522123445177c0d67820c5 Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 15:33:49 -0500 Subject: [PATCH 6/7] Keep public workflow free of private integrations --- .../workflows/auto-merge-openapi-updates.yml | 223 +++--------------- 1 file changed, 31 insertions(+), 192 deletions(-) diff --git a/.github/workflows/auto-merge-openapi-updates.yml b/.github/workflows/auto-merge-openapi-updates.yml index 8144a7d6d8..7dafc61577 100644 --- a/.github/workflows/auto-merge-openapi-updates.yml +++ b/.github/workflows/auto-merge-openapi-updates.yml @@ -8,17 +8,12 @@ name: Auto-merge OpenAPI description updates # `GET /pulls/{n}/files` returns 422 on them, so the merge is done with plain # git against a blobless clone. # -# This automates step 6 of the API Platform first-responder runbook -# (`openapi-pr-reviewer`). The runbook runs it on Tuesdays and Thursdays; this -# runs continuously, so the PR backlog never accumulates. The safety properties -# the FR provides by hand are preserved: lint must be green, a semantic -# breaking-change scan must come back clean, and a change summary is posted to -# the PR before merge for release-note authoring. +# 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, when -# Docs asks the team to stop merging description PRs until the corresponding -# RC PR lands in github/docs-internal. See the "Check for an active merge -# freeze" step for how that is detected and how it clears. +# 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: @@ -29,10 +24,6 @@ on: description: 'Analyze and report, but do not merge or close anything' type: boolean default: false - test_notify: - description: 'Send a test post to #api-platform to prove the chatterbox route works' - type: boolean - default: false permissions: contents: write @@ -57,87 +48,8 @@ jobs: status: ${{ steps.merge.outputs.status }} detail: ${{ steps.merge.outputs.detail }} steps: - - name: Preflight - verify credentials - id: preflight - env: - MERGE_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN }} - CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} - CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} - GH_REPO: ${{ github.repository }} - TEST_NOTIFY: ${{ inputs.test_notify }} - run: | - set -euo pipefail - - fail='' - - # --- Merge token --------------------------------------------------- - # Falling back to github.token is fine, but a token that is *set* and - # broken (expired PAT, revoked, wrong scopes) must fail loudly here - # rather than as an opaque `git push` rejection after the merge. - if [ -z "${MERGE_TOKEN:-}" ]; then - echo "::warning::OPENAPI_MERGE_TOKEN is not set; falling back to GITHUB_TOKEN. Pushes will not trigger downstream workflows." - else - if ! login=$(GH_TOKEN="$MERGE_TOKEN" gh api user -q '.login' 2>/dev/null); then - # Fine-grained tokens and app installation tokens cannot call - # /user, so only treat this as fatal if the repo probe also fails. - login='(unknown; /user not available for this token type)' - fi - - # `gh api` writes its error body to stdout, so a `|| echo ERROR` - # sentinel would be appended to that body rather than replacing it. - # Branch on the exit status instead. - if perms=$(GH_TOKEN="$MERGE_TOKEN" gh api "repos/$GH_REPO" \ - -q '"\(.permissions.push)\t\(.permissions.admin)"' 2>/dev/null); then - push=${perms%%$'\t'*} - if [ "$push" != 'true' ]; then - fail+="OPENAPI_MERGE_TOKEN cannot push to $GH_REPO (contents:write missing; permissions.push=$push). " - else - echo "Merge token OK. Identity: $login, push: $push" - fi - else - fail+="OPENAPI_MERGE_TOKEN is set but cannot read $GH_REPO (expired, revoked, or lacking repo access). " - fi - fi - - # --- Chatterbox ---------------------------------------------------- - # The notifier is the only signal that a breaking change was skipped, - # so a silently-dead route would make the guard useless. - if [ -z "${CHATTERBOX_URL:-}" ] || [ -z "${CHATTERBOX_TOKEN:-}" ]; then - fail+="CHATTERBOX_URL/CHATTERBOX_TOKEN are not both set; breaking-change alerts would go nowhere. " - else - # curl already writes `000` on connection failure *and* exits - # non-zero, so a `|| echo 000` fallback would concatenate into - # `000000`. Swallow the exit status with `|| true` instead. - code=$(curl --silent --output /dev/null --write-out '%{http_code}' \ - --max-time 20 \ - -u "${CHATTERBOX_TOKEN}:" \ - "${CHATTERBOX_URL%/}/topics/%23api-platform" \ - --data ':white_check_mark: OpenAPI auto-merge preflight: chatterbox route is alive.' \ - || true) - - case "${code:-000}" in - 2*) echo "Chatterbox OK (HTTP $code)." ;; - 000|'') fail+="Chatterbox unreachable (connection failed or timed out). " ;; - 401|403) fail+="Chatterbox rejected CHATTERBOX_TOKEN (HTTP $code). " ;; - *) fail+="Chatterbox returned HTTP $code. " ;; - esac - fi - - if [ -n "$fail" ]; then - echo "::error::Preflight failed: $fail" - exit 1 - fi - - if [ "${TEST_NOTIFY:-false}" = 'true' ]; then - echo "test_notify requested; preflight post sent. Stopping before any merge." - echo "stop=true" >>"$GITHUB_OUTPUT" - fi - - echo "Preflight passed." - - name: Select candidate PRs id: select - if: steps.preflight.outputs.stop != 'true' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -239,22 +151,10 @@ jobs: run: | set -euo pipefail - # Around a GHES release candidate, Docs asks the team to hold off - # merging description PRs. The sequence is: - # 1. github/github sets `published: true` for the new GHES version - # 2. Docs asks #api-platform to hold merges - # 3. Docs merges the RC PR in github/docs-internal - # 4. Docs gives the all-clear and merges resume - # - # Step 1 is what makes the new GHES version's descriptions appear in - # the bot's PR here, so "this PR introduces a GHES version that does - # not exist on the default branch" is a reliable proxy for being - # inside that window. It is also self-clearing: once the version is - # merged the directory exists on main and later PRs stop matching. - # - # The window is genuinely short (for 3.21, descriptions landed 18 - # minutes before the docs RC merged), so this must be checked per-run - # rather than assumed stale. + # 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='' @@ -303,7 +203,7 @@ jobs: # allowing real merges. Blobs for the touched files are fetched lazily. filter: blob:none fetch-depth: 0 - token: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + token: ${{ github.token }} - name: Set up Python if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' @@ -334,7 +234,7 @@ jobs: # 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 - # github/api-platform docs/creating-an-openapi-release.md. + # the project's documented release-compatibility rules. findings='' : >summary.md @@ -388,7 +288,7 @@ jobs: - name: Post change summary to PRs if: steps.checks.outputs.status == 'green' && steps.freeze.outputs.status == 'clear' env: - GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_30: ${{ steps.select.outputs.pr_30 }} PR_31: ${{ steps.select.outputs.pr_31 }} @@ -396,10 +296,9 @@ jobs: run: | set -euo pipefail - # Release notes are generated from PR descriptions, and the runbook - # asks for a human-written change summary before merge. Auto-merging - # untouched bodies would delete that input, so post the analysis the - # scanner already computed. + # 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" @@ -423,7 +322,7 @@ jobs: steps.freeze.outputs.status == 'clear' && steps.breaking.outputs.status == 'clean' env: - GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} + GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_30: ${{ steps.select.outputs.pr_30 }} PR_31: ${{ steps.select.outputs.pr_31 }} @@ -447,7 +346,7 @@ jobs: merged='' # 3.0 first, then 3.1: they touch disjoint trees but this ordering - # matches the manual runbook and keeps history readable. + # 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 @@ -470,80 +369,20 @@ jobs: echo "status=merged" >>"$GITHUB_OUTPUT" echo "detail=Merged $merged" >>"$GITHUB_OUTPUT" - - name: Notify #api-platform + - name: Summarize exceptions if: >- - steps.preflight.outcome == 'success' && - (failure() || - steps.breaking.outputs.status == 'breaking' || - steps.freeze.outputs.status == 'frozen') - env: - CHATTERBOX_URL: ${{ secrets.CHATTERBOX_URL }} - CHATTERBOX_TOKEN: ${{ secrets.CHATTERBOX_TOKEN }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - PR_30: ${{ steps.select.outputs.pr_30 }} - PR_31: ${{ steps.select.outputs.pr_31 }} - BREAKING: ${{ steps.breaking.outputs.detail }} - FREEZE: ${{ steps.freeze.outputs.detail }} - GH_TOKEN: ${{ secrets.OPENAPI_MERGE_TOKEN || github.token }} - GH_REPO: ${{ github.repository }} + failure() || + steps.breaking.outputs.status == 'breaking' || + steps.freeze.outputs.status == 'frozen' run: | - set -euo pipefail - - if [ -z "${CHATTERBOX_URL:-}" ] || [ -z "${CHATTERBOX_TOKEN:-}" ]; then - echo "Chatterbox not configured; skipping notification." - exit 0 - fi - - if [ -n "${FREEZE:-}" ]; then - # A freeze is an expected state, not a fault. It is re-detected - # every 2 hours for the whole GHES RC window, so announce it once - # per PR rather than pinging the channel dozens of times. The PR - # comment is the durable marker; a workspace file would not survive - # between runs. - already='' - for pr in $PR_30 $PR_31; do - [ -n "$pr" ] || continue - if gh pr view "$pr" --json comments \ - -q '.comments[].body' 2>/dev/null | grep -q 'AUTO-MERGE-FREEZE-NOTICE'; then - already='yes' - fi - done - - if [ -n "$already" ]; then - echo "Freeze already announced for these PRs; not re-posting." - exit 0 + { + 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 - - for pr in $PR_30 $PR_31; do - [ -n "$pr" ] || continue - gh pr comment "$pr" --body "$(printf '%s\n' \ - '## :snowflake: Auto-merge is holding (merge freeze)' \ - '' \ - "Reason: ${FREEZE}" \ - '' \ - 'This is expected around a GHES release candidate, while Docs merges the corresponding RC PR in `github/docs-internal`. Merges resume automatically once the new GHES version is present on the default branch and any open `merge-freeze` issue is closed. No action needed unless this persists past the all-clear.' \ - '' \ - '')" || true - done - - headline=":snowflake: OpenAPI auto-merge is holding: merge freeze detected." - body="• Reason: ${FREEZE}"$'\n'"• Merges resume automatically once the new GHES version is on the default branch and any 'merge-freeze' issue is closed." - elif [ -n "${BREAKING:-}" ]; then - headline=":rotating_light: OpenAPI auto-merge skipped: potential breaking changes need a human." - body="• Findings: ${BREAKING}" - else - headline=":warning: OpenAPI auto-merge failed in ${GITHUB_REPOSITORY}." - body="• Needs manual merge" - fi - - message=$(printf '%s\n' \ - "$headline" \ - "• PRs: #${PR_30:-n/a} (3.0), #${PR_31:-n/a} (3.1)" \ - "$body" \ - "• Run: ${RUN_URL}") - - curl --fail --silent --show-error \ - -X POST \ - -u "${CHATTERBOX_TOKEN}:" \ - "${CHATTERBOX_URL%/}/topics/%23api-platform" \ - --data "$message" + } >>"$GITHUB_STEP_SUMMARY" From 26daecdf1604ec27d095d9802d3832b5e52d9bcf Mon Sep 17 00:00:00 2001 From: Shawn Hartsell Date: Fri, 14 Aug 2026 15:33:50 -0500 Subject: [PATCH 7/7] Remove internal references from public scanner --- .github/scripts/openapi-breaking-changes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/scripts/openapi-breaking-changes.py b/.github/scripts/openapi-breaking-changes.py index 1eb65f4673..097e8d4577 100644 --- a/.github/scripts/openapi-breaking-changes.py +++ b/.github/scripts/openapi-breaking-changes.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 """Detect breaking changes between two OpenAPI description files. -Implements the breaking-change list from -`github/api-platform/docs/creating-an-openapi-release.md`: +Implements the project's documented breaking-change list: 1. An operationId name has been changed. 2. A URL parameter name has been changed.