-
Notifications
You must be signed in to change notification settings - Fork 336
364 lines (316 loc) · 14.6 KB
/
Copy pathauto-merge-openapi-updates.yml
File metadata and controls
364 lines (316 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
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.' \
|| 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 \
--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: >-
steps.preflight.outcome == 'success' &&
(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"