-
Notifications
You must be signed in to change notification settings - Fork 336
Auto-merge OpenAPI description update PRs #6993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
shawnHartsell
wants to merge
13
commits into
main
Choose a base branch
from
auto-merge-openapi-updates
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 2 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
bd6a129
Add auto-merge workflow for OpenAPI description update PRs
shawnHartsell 1b17c18
Hard-fail preflight for merge token and chatterbox route
shawnHartsell 0453e20
Add semantic OpenAPI breaking-change detector
shawnHartsell 2c3eca7
Use semantic breaking-change scan and post change summary to PRs
shawnHartsell 46ef8af
Hold merges during GHES release-candidate freeze windows
shawnHartsell 0d9bf49
Keep public workflow free of private integrations
shawnHartsell 26daecd
Remove internal references from public scanner
shawnHartsell 281260e
Harden auto-merge selection, SHA pinning and compatibility scan
shawnHartsell 2d8105d
Close remaining auto-merge gaps found in review
shawnHartsell 518dbf8
Fail closed on unverifiable state and widen scan rules
shawnHartsell adbbefa
Drop duplicate compatibility scan, gate scope before commenting
shawnHartsell 107496b
Merge through the pull request API and report to chat
shawnHartsell a2a9800
Read every open pull request when selecting
shawnHartsell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,364 @@ | ||
| 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 | ||
| test_notify: | ||
| description: 'Send a test post to #api-platform to prove the chatterbox route works' | ||
| 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: 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.' \ | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| || 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 }} | ||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| open_prs=$(gh pr list \ | ||
| --state open \ | ||
| --author "$BOT_LOGIN" \ | ||
| --limit 100 \ | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| --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") | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| 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) | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| [ -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_.-]+$') | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| # 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 | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| 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: >- | ||
| steps.preflight.outcome == 'success' && | ||
| (failure() || steps.breaking.outputs.status == 'breaking') | ||
|
shawnHartsell marked this conversation as resolved.
Outdated
|
||
| 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" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.