Skip to content
Draft
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 364 additions & 0 deletions .github/workflows/auto-merge-openapi-updates.yml
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
Comment thread
shawnHartsell marked this conversation as resolved.

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.' \
Comment thread
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 \
Comment thread
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")
Comment thread
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)
Comment thread
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_.-]+$')
Comment thread
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
Comment thread
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')
Comment thread
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"
Loading