-
Notifications
You must be signed in to change notification settings - Fork 10.5k
216 lines (207 loc) · 12.2 KB
/
Copy pathbackport-automerge.yml
File metadata and controls
216 lines (207 loc) · 12.2 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
name: backport-automerge
# Auto-merge a clean backport PR once its CI is green — and ping Feishu if its CI fails.
#
# release/v* is protected (PR required) but has no required status check and no merge queue,
# so neither GitHub auto-merge nor enqueuePullRequest applies. Instead we react to ci.yml
# finishing for a backport-* branch:
# - CI success + clean (non-draft) PR -> squash-merge it (App token, so the push to
# release/* re-triggers notify-release-feishu / the nightly).
# - CI failure -> Feishu ping for manual follow-up.
# - conflict (draft PR) -> left for a human; release-gate blocks the release
# until it lands.
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
pull-requests: write # github-actions[bot] approves the bot's own clean backport (Approve step)
jobs:
followup:
# Only react to CI that ran for a *pull request* on a backport-* branch — never a push or
# manual dispatch — so a non-PR run can't reach the App-token / Feishu steps below.
if: ${{ github.repository == 'nexu-io/open-design' && github.event.workflow_run.event == 'pull_request' && startsWith(github.event.workflow_run.head_branch, 'backport-') }}
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Checkout trusted repository history
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Resolve the backport PR for this run
id: pr
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
# Authoritative run -> PR association from GitHub (populated for same-repo PRs; empty
# for forks, which we never auto-merge anyway).
RUN_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
run: |
set -euo pipefail
# Pick the run's own PR that targets a release/* branch at exactly the SHA CI ran on —
# bound to workflow_run.pull_requests, not a branch-name guess.
num="$(printf '%s' "$RUN_PRS" | jq -r --arg sha "$HEAD_SHA" \
'[.[] | select(.base.ref | startswith("release/v")) | select(.head.sha == $sha)][0].number // empty')"
if [ -z "$num" ]; then
echo "found=false" >> "$GITHUB_OUTPUT"
echo "Run has no associated release/* PR at $HEAD_SHA — skipping"
exit 0
fi
row="$(gh pr view "$num" --repo "$REPO" \
--json number,baseRefName,baseRefOid,isDraft,title,body,author,isCrossRepository,headRefOid)"
# "Pristine" = the backport's cumulative diff is patch-equivalent to the source PR's
# reviewed merge commit on main. Do NOT trust commit author/committer identity here:
# those are plain git headers, and a collaborator can forge github-actions[bot]'s
# committer email on an otherwise human-pushed commit. GitHub API-created commits are
# unsigned in this path, and korthout's legitimate cherry-picks are unsigned too, so
# signature fields cannot prove bot provenance either. Bound what auto-merge can land
# instead: if a human pushes a conflict resolution, CI fix, or malicious extra change,
# the cumulative patch-id differs from the source PR's already-reviewed main diff and
# pristine=false; the PR then falls back to human review.
body="$(printf '%s' "$row" | jq -r '.body // ""')"
source_num="$(printf '%s\n' "$body" | sed -nE 's/.*Backport of #([0-9]+) .*/\1/p' | head -n 1)"
source_merge=""
source_base=""
source_state=""
source_merged_at=""
backport_base=""
if [ -n "$source_num" ]; then
source="$(gh pr view "$source_num" --repo "$REPO" \
--json state,mergedAt,mergeCommit,baseRefName 2>/dev/null || true)"
if [ -n "$source" ]; then
source_state="$(printf '%s' "$source" | jq -r '.state // ""')"
source_merged_at="$(printf '%s' "$source" | jq -r '.mergedAt // ""')"
source_base="$(printf '%s' "$source" | jq -r '.baseRefName // ""')"
source_merge="$(printf '%s' "$source" | jq -r '.mergeCommit.oid // ""')"
fi
fi
base_oid="$(printf '%s' "$row" | jq -r '.baseRefOid')"
head_oid="$(printf '%s' "$row" | jq -r '.headRefOid')"
source_patch_id=""
backport_patch_id=""
if [ -n "$source_merge" ] \
&& [ "$source_state" = "MERGED" ] \
&& [ -n "$source_merged_at" ] \
&& [ "$source_base" = "main" ] \
&& [ -n "$base_oid" ] \
&& [ -n "$head_oid" ]; then
git fetch --no-tags origin \
"+refs/heads/$(printf '%s' "$row" | jq -r '.baseRefName'):refs/remotes/origin/$(printf '%s' "$row" | jq -r '.baseRefName')" \
"+refs/pull/$num/head:refs/remotes/pull/$num/head"
backport_base="$(git merge-base "$base_oid" "$head_oid" 2>/dev/null || true)"
if [ -n "$backport_base" ]; then
source_patch_id="$(git diff --binary "$source_merge^" "$source_merge" | git patch-id --verbatim | awk '{print $1}')"
backport_patch_id="$(git diff --binary "$backport_base" "$head_oid" | git patch-id --verbatim | awk '{print $1}')"
fi
fi
if [ -n "$source_patch_id" ] && [ "$source_patch_id" = "$backport_patch_id" ]; then
pristine=true
else
pristine=false
echo "Backport PR #$num is not patch-equivalent to source PR #${source_num:-unknown}; skipping auto-merge"
fi
{
echo "found=true"
echo "number=$(printf '%s' "$row" | jq -r .number)"
echo "base=$(printf '%s' "$row" | jq -r .baseRefName)"
echo "draft=$(printf '%s' "$row" | jq -r .isDraft)"
echo "title=$(printf '%s' "$row" | jq -r .title)"
echo "author=$(printf '%s' "$row" | jq -r '.author.login')"
echo "cross=$(printf '%s' "$row" | jq -r '.isCrossRepository')"
echo "head_oid=$head_oid"
echo "pristine=$pristine"
} >> "$GITHUB_OUTPUT"
- name: Approve the clean backport (release bot, CI green)
# release/v* requires 1 approving review. The diff was already reviewed + approved on
# main; this is a mechanical, conflict-free cherry-pick by the release bot with green CI.
# Approve as github-actions[bot] — a DIFFERENT identity than the App that authored the PR
# (an author can't approve its own PR) — under the SAME bot-identity + SHA + CI gates as
# the merge below, so only the release bot's own verified backports are auto-approved.
# pristine == 'true' is critical here: it means the release-branch diff is patch-equivalent
# to the reviewed source PR merge commit on main. If a human resolved a conflict or fixed
# CI with a different diff, pristine is false and this skips — that code must get a real
# human review.
if: >-
${{ steps.pr.outputs.found == 'true'
&& steps.pr.outputs.draft == 'false'
&& steps.pr.outputs.pristine == 'true'
&& steps.pr.outputs.author == 'app/open-design-release-bot'
&& steps.pr.outputs.cross == 'false'
&& steps.pr.outputs.head_oid == github.event.workflow_run.head_sha
&& github.event.workflow_run.conclusion == 'success' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
NUM: ${{ steps.pr.outputs.number }}
run: |
set -euo pipefail
gh pr review "$NUM" --repo "$REPO" --approve \
--body "Auto-approved: clean cherry-pick by the release bot of code already reviewed on main, CI green. (release/v* requires one approval.)"
- name: Auto-merge clean backport on green CI
# Guards (release/v* has no required checks, so this workflow IS the gate):
# - author == the release bot and same-repo (not a fork) — so a stranger can't get a
# branch named backport-* auto-merged with the App token.
# - PR head == the exact SHA CI passed on (head_oid == workflow_run.head_sha), and
# --match-head-commit re-checks at merge time — so a commit pushed after CI went
# green is never merged untested.
# - pristine == 'true' — the backport cumulative diff is patch-equivalent to the source
# PR merge commit that was reviewed on main. Once the branch's net diff differs from the
# reviewed-on-main code, we fall back to human review.
if: >-
${{ steps.pr.outputs.found == 'true'
&& steps.pr.outputs.draft == 'false'
&& steps.pr.outputs.pristine == 'true'
&& steps.pr.outputs.author == 'app/open-design-release-bot'
&& steps.pr.outputs.cross == 'false'
&& steps.pr.outputs.head_oid == github.event.workflow_run.head_sha
&& github.event.workflow_run.conclusion == 'success' }}
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
REPO: ${{ github.repository }}
NUM: ${{ steps.pr.outputs.number }}
BASE: ${{ steps.pr.outputs.base }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
echo "CI green on $HEAD_SHA — squash-merging clean backport PR #$NUM into $BASE"
gh pr merge "$NUM" --repo "$REPO" --squash --delete-branch --match-head-commit "$HEAD_SHA"
- name: Notify Feishu on failed backport CI
# Same identity gates as the merge step: only ping for a genuine bot backport from the
# same repo, so a fork / non-bot PR named backport-* can't spam the release group.
# The head_oid == workflow_run.head_sha gate matches the approve/merge steps: a stale
# failed run can finish after the PR head advanced, and should not page about a SHA that is
# no longer the PR head. Page on failure and timed_out, but not cancelled: force-pushes can
# cancel superseded PR runs as part of normal backport iteration.
if: ${{ steps.pr.outputs.found == 'true' && steps.pr.outputs.author == 'app/open-design-release-bot' && steps.pr.outputs.cross == 'false' && steps.pr.outputs.head_oid == github.event.workflow_run.head_sha && (github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'timed_out') }}
env:
FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }}
FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }}
NUM: ${{ steps.pr.outputs.number }}
BASE: ${{ steps.pr.outputs.base }}
TITLE: ${{ steps.pr.outputs.title }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
run: |
python3 - <<'PY'
import os, time, hmac, hashlib, base64, json, urllib.request
wh = os.environ.get("FEISHU_WEBHOOK", "")
if not wh:
raise SystemExit(0)
secret = os.environ.get("FEISHU_SIGN_SECRET", "")
text = (f"⚠️ backport PR #{os.environ['NUM']} CI {os.environ.get('CONCLUSION','failed')}(目标 {os.environ['BASE']})\n"
f"{os.environ.get('TITLE','')}\n需要人工跟进:{os.environ.get('RUN_URL','')}")
body = {"msg_type": "text", "content": {"text": text}}
if secret:
ts = str(int(time.time()))
sign = base64.b64encode(hmac.new(f"{ts}\n{secret}".encode(), b"", hashlib.sha256).digest()).decode()
body = {"timestamp": ts, "sign": sign, **body}
req = urllib.request.Request(wh, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
print(urllib.request.urlopen(req).read().decode())
PY