Files
open-design/.github/workflows/bake-plugin-previews-automerge.yml
lefarcenandGitHub 60d187b79f feat(ci): auto-merge the rolling plugin-preview manifest PR on green CI (#4727)
* feat(ci): auto-merge the rolling plugin-preview manifest PR on green CI

The post-merge bake opens ONE rolling PR (chore/plugin-previews) whenever a
baked preview changes (spec slice 1). Its diff is pure generated data
(data/plugin-previews/manifest.json; the clips already live on R2), yet someone
still had to merge it by hand — the last manual step the pipeline rework left
behind, and exactly the kind of bot PR that used to pile up.

Two coupled changes:

1. bake-plugin-previews.yml now authors the rolling branch + PR with the
   release-bot App token instead of GITHUB_TOKEN. A GITHUB_TOKEN-authored push
   does not trigger workflows, so the manifest PR never ran `Validate workspace`
   (main's required check) and could not clear the merge queue at all — even
   manually. (PREVIEW_BAKE_TOKEN, the old fallback, was never configured.)

2. bake-plugin-previews-automerge.yml reacts to ci.yml completing for the rolling
   branch and, for a non-draft same-repo PR authored by the release bot with only
   bot commits (pristine) at the exact SHA CI passed, approves it as
   github-actions[bot] (main requires one review; an author cannot approve its own
   PR) and enqueues it with `gh pr merge --auto --squash --match-head-commit`.
   CI failure pings Feishu instead.

Modeled on backport-automerge.yml, adapted for main: main has a merge queue, so
the merge is GitHub-native --auto (enqueue) rather than a direct squash, and
pristine checks the git committer email (the bot email maps to no GitHub user, so
.committer.login is null).

* fix(review): GitHub-backed pristine provenance + correct Feishu sign secret

Address two blocking review findings on the auto-merge path:

1. pristine could be satisfied by spoofed commit metadata. The bake commit was
   authored with a plaintext `git config user.email "bot@open-design.ai"`, and
   the reactor trusted that raw committer email — any collaborator who can push
   to chore/plugin-previews could set the same email and fake pristine=true,
   landing unreviewed commits on main. Commit AS github-actions[bot] (its
   canonical noreply email) and gate on GitHub's resolved `.committer.login`,
   the same GitHub-backed field backport-automerge.yml trusts.

2. the failure Feishu ping signed with secrets.FEISHU_WEBHOOK_SECRET, but every
   other FEISHU_RELEASE_WEBHOOK caller (notify-release-feishu, notify-daily-feishu,
   backport-automerge) signs with FEISHU_RELEASE_SIGN_SECRET; the wrong secret
   resolves empty and the signed release webhook rejects the unsigned request.
   Use FEISHU_RELEASE_SIGN_SECRET and assert the pairing in the topology test.

* test(ci): lock the auto-merge conclusion gates + bake producer invariants

Address nettee's non-blocking coverage follow-up so a regression can't pass the
topology test green:

- Assert the approve and enqueue steps gate on
  `workflow_run.conclusion == 'success'`, and the Feishu step on `== 'failure'`,
  so dropping a conclusion predicate fails the test.
- Add a companion `[P2]` block over bake-plugin-previews.yml asserting the three
  producer invariants the downstream pristine/auto-merge path depends on:
  the create-github-app-token mint, the App-token push URL, and the
  github-actions[bot] commit identity. Regressing the producer back to a
  GITHUB_TOKEN push or a different commit identity now fails here.

* fix(review): bind the Feishu failure alert to the run's exact SHA

A stale failed CI run can finish after the force-pushed rolling branch has
advanced to a newer (green) head. The Feishu step only gated on author/cross +
conclusion==failure, so it would page about a SHA that is no longer the PR head.
Add the same steps.pr.outputs.head_oid == workflow_run.head_sha guard the
approve/enqueue steps use, and lock it in the topology test.

* fix(review): prove bot provenance with GitHub signatures, not git headers

nettee correctly escalated: .committer.login is still a spoofable git-header
identity. A collaborator who can push to chore/plugin-previews can mint a local
commit with the bot's noreply email, GitHub then reports
.committer.login == github-actions[bot], and pristine=true passes — landing
unreviewed human changes on main.

Switch to non-spoofable provenance:

- bake-plugin-previews.yml now creates the manifest commit through the Git Data
  API (blob -> tree -> commit -> ref) with the App token, instead of a local
  `git commit` + push. A commit created through the API is signed by GitHub
  (verification.verified=true) and committed by the App bot (committer.type=Bot).
  Building the tree on the live main HEAD also rebases the rolling branch onto
  the latest main each run.
- the reactor's pristine gate now requires EVERY commit to be both
  verification.verified==true AND committer.type==Bot. A human's locally pushed
  commit is unsigned (verified=false) even with the bot email; a human signing
  with their own key is committer.type=User. Neither can be both, so the pair is
  forge-proof.
- topology test updated to lock the API-commit producer path (and forbid a
  regression to a local push) and the verified+Bot pristine check.

* fix(review): parent the manifest commit on the rendered revision, not live main

The previous change based the Git Data API commit on live main HEAD, but $NEW is
rendered from the job's checkout at start. The bake can run ~90min and main can
advance (unrelated merges) meanwhile, so basing the commit on live main would
publish a manifest from an older checkout on top of newer commits — the rolling
PR would look current while carrying stale preview data and could overwrite
newer plugin-preview changes.

Parent on `git rev-parse HEAD` (the exact revision that produced $NEW) instead,
so GitHub correctly shows the PR as behind once main moves and the next bake
re-renders on the newer HEAD. Topology test locks the rendered-revision base and
forbids regressing to a live-main base.

* fix(review): require the rolling PR's rendered base to equal main before merge

main's merge queue does not require an up-to-date branch, so a bake rendered
against an older main could be re-tested on the latest main and squash-merged
with a stale manifest.json — overwriting a newer manifest that landed meanwhile.
head_oid only proves the PR head is the SHA CI validated, not that it is current.

Add a base-freshness gate: the rolling commit's first parent is the revision the
manifest was rendered against, so require it to equal current main HEAD before
approving/enqueuing. A behind PR is skipped and refreshed by the next bake (the
nightly re-renders on the newer tip even if no plugin changed). Locked in the
topology test.

* fix(review): page Feishu on timed_out bakes too, not just failure

workflow_run has terminal RED states beyond 'failure'. ci.yml defines many
timeout-minutes jobs, so a timed-out run on the rolling manifest PR would block
auto-merge with no Feishu page. Broaden the alert predicate to
failure OR timed_out, and include the conclusion in the message. Deliberately
exclude 'cancelled': a force-push of the rolling branch routinely cancels the
prior in-flight run, so alerting on it would page on every normal refresh. Test
locks the failure+timed_out set and the cancelled exclusion.

* fix(review): gate auto-merge on a manifest-only diff, not commit signatures

nettee is right that the Git Data API does not sign commits — I verified it
empirically: a blob/tree/commit created via POST /git/commits comes back
verification.verified=false / reason=unsigned (and the Contents API is unsigned
too for non-web callers). So the previous verified==true && committer.type==Bot
pristine gate would never pass and the rolling PR could never auto-merge.

There is no per-commit field that is both bot-set and unforgeable without a
signature, so stop trying to prove "the bot authored every commit" and instead
bound what auto-merge can land: require the PR's changed files to be EXACTLY
data/plugin-previews/manifest.json. A human with push access can still push to
the rolling branch, but any commit that touches a code file (or anything but the
manifest) makes the file set != the single manifest path, so pristine=false and
the PR falls back to human review. The worst a forged manifest-only change can do
is point a gallery preview at a bad video URL — generated data the next bake
overwrites — never unreviewed code on main.

With signing no longer relied upon, revert the producer from the Git Data API
dance back to a plain App-token git push (still parented on the checked-out HEAD
per the base-freshness fix, still pushed with the App token so CI runs). Tests
updated to lock the manifest-only file gate and the simplified producer.

* test(ci): update secretless-bake assertion for the App-token producer

Merge main (#4691 landed the read-only pre-merge bake + its topology test). That
test asserted the post-merge workflow contains PREVIEW_BAKE_TOKEN as the
write-capable credential; this PR replaced that never-configured fallback with
the release-bot App, so assert secrets.RELEASE_BOT_APP_ID (and that
PREVIEW_BAKE_TOKEN is gone) instead.
2026-06-25 05:53:01 +00:00

211 lines
12 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: bake-plugin-previews-automerge
# Auto-merge the rolling plugin-preview manifest PR once its CI is green — and
# ping Feishu if its CI fails.
#
# bake-plugin-previews.yml opens ONE rolling PR (branch chore/plugin-previews,
# authored by the release-bot App) whenever a baked preview changes. The diff is
# pure generated data (data/plugin-previews/manifest.json; the clips already live
# on R2) validated by the bake + CI, so a human merging it by hand is the last
# manual step the pipeline rework left behind — and exactly the kind of bot PR
# that piled up before. React to ci.yml finishing for that branch:
# - CI success + same-repo App PR + pristine (only bot commits) -> approve as a
# DIFFERENT bot, then enqueue it (main has a merge queue + 1 required review).
# - CI failure -> Feishu ping for manual follow-up.
#
# main (unlike release/v*) has a merge queue, so the merge is GitHub-native
# `--auto`: it enqueues once `Validate workspace` is green. The App authors the
# PR (so its commit triggers CI); github-actions[bot] approves it (an author
# cannot approve its own PR).
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
pull-requests: write # github-actions[bot] approves the bot's own clean manifest PR
jobs:
followup:
# Only react to CI that ran for a *pull request* on the rolling manifest branch —
# never a push or manual dispatch — so a non-PR run can't reach the App-token /
# merge steps below.
if: ${{ github.repository == 'nexu-io/open-design' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.head_branch == 'chore/plugin-previews' }}
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: Resolve the manifest 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 the bot never authors anyway).
RUN_PRS: ${{ toJSON(github.event.workflow_run.pull_requests) }}
run: |
set -euo pipefail
# Pick the run's own PR that targets main 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 == "main") | select(.head.sha == $sha)][0].number // empty')"
if [ -z "$num" ]; then
echo "found=false" >> "$GITHUB_OUTPUT"
echo "Run has no associated main PR at $HEAD_SHA — skipping"
exit 0
fi
row="$(gh pr view "$num" --repo "$REPO" \
--json number,baseRefName,isDraft,title,author,isCrossRepository,headRefOid)"
# "Pristine" = the PR is a pure manifest-data change: its changed files are EXACTLY
# data/plugin-previews/manifest.json and nothing else. This is the integrity gate that
# makes auto-merge safe. A human with write access can push a commit onto
# chore/plugin-previews, and a commit's author/committer identity is just a git header
# they can forge (even to a bot's noreply email), so identity cannot prove the bot
# authored it — and the Git Data API does NOT sign commits, so a signature gate would
# never pass either. Instead bound what auto-merge can land: any commit that touches a
# code file (or anything other than the manifest) makes the PR file set != the single
# manifest path, so pristine=false and the PR falls back to human review. The worst a
# forged manifest-only change can do is point a gallery preview at a bad video URL —
# generated data the next bake overwrites — never unreviewed code on main.
# Paginate across ALL files — a >100-file PR must not pass on its first page; one
# non-manifest file anywhere fails it. Fail closed on a partial/failed lookup: capture
# gh's exit via `if !` and blank the list, so pristine needs a complete, successful
# enumeration of every changed file.
if ! files="$(gh api "repos/$REPO/pulls/$num/files?per_page=100" --paginate \
--jq '.[].filename' 2>/dev/null)"; then
files=""
fi
nonmanifest="$(printf '%s\n' "$files" | grep -Fvxc 'data/plugin-previews/manifest.json' || true)"
if [ -n "$files" ] && [ "${nonmanifest:-1}" = "0" ]; then
pristine=true
else
pristine=false
fi
# Base-freshness: the rolling commit is `rendered_base + one manifest commit`, so its
# first parent IS the revision the manifest was rendered against (bake-plugin-previews.yml
# parents on `git rev-parse HEAD`). main's merge queue does NOT require an up-to-date
# branch, so if main advanced past that base a newer manifest may already be on main and a
# squash would overwrite it with stale data. Require the rendered base to equal current
# main HEAD; otherwise skip — the next bake (including the nightly) re-renders on the newer
# tip and refreshes the rolling PR. Fail closed (base_fresh=false) on any lookup error.
RENDERED_BASE="$(gh api "repos/$REPO/commits/$HEAD_SHA" --jq '.parents[0].sha // ""' 2>/dev/null || true)"
MAIN_HEAD="$(gh api "repos/$REPO/commits/heads/main" --jq '.sha // ""' 2>/dev/null || true)"
if [ -n "$RENDERED_BASE" ] && [ "$RENDERED_BASE" = "$MAIN_HEAD" ]; then
base_fresh=true
else
base_fresh=false
echo "rendered base $RENDERED_BASE != main $MAIN_HEAD — PR is behind, skipping auto-merge until the next bake refreshes it"
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=$(printf '%s' "$row" | jq -r '.headRefOid')"
echo "pristine=$pristine"
echo "base_fresh=$base_fresh"
} >> "$GITHUB_OUTPUT"
- name: Approve the clean manifest PR (github-actions bot, CI green)
# main requires 1 approving review. The diff is pure generated manifest data
# the bake produced and CI validated; approve as github-actions[bot] — a
# DIFFERENT identity than the App that authored the PR (an author cannot
# approve its own PR) — under the same identity + SHA + CI + pristine gates as
# the merge below. pristine == 'true' guarantees no human commit slipped in.
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
&& steps.pr.outputs.base_fresh == 'true'
&& 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: rolling plugin-preview manifest refreshed by the bake bot, CI green. (main requires one approval.)"
- name: Enqueue the clean manifest PR on green CI
# Guards (main IS protected by a merge queue + 1 review, so --auto enqueues):
# - author == the release bot and same-repo (not a fork) — so a stranger
# can't get a chore/plugin-previews PR 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 enqueue time — so a commit pushed
# after CI went green is never merged untested.
# - pristine == 'true' — every commit was committed by the bake bot; once a
# human touches the branch the diff is no longer bot-generated data, so we
# fall back to human review.
# - base_fresh == 'true' — the rendered base equals current main HEAD. The merge
# queue does not require an up-to-date branch, so without this a bake rendered
# against an older main could be re-tested and squashed in, overwriting a newer
# manifest. A behind PR is left for the next bake to refresh.
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
&& steps.pr.outputs.base_fresh == 'true'
&& github.event.workflow_run.conclusion == 'success' }}
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
REPO: ${{ github.repository }}
NUM: ${{ steps.pr.outputs.number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
echo "CI green on $HEAD_SHA — enqueuing manifest PR #$NUM into main's merge queue"
gh pr merge "$NUM" --repo "$REPO" --squash --auto --match-head-commit "$HEAD_SHA"
- name: Notify Feishu on failed manifest CI
# Same identity gates as the merge step: only ping for a genuine bot manifest
# PR from the same repo, so a fork / non-bot PR can't spam the release group.
# The head_oid == workflow_run.head_sha gate matches the approve/enqueue steps:
# the rolling branch is force-pushed, so an older failed run can finish after
# the PR head already advanced to a newer (green) commit. Without this guard a
# stale failure would page Feishu about a SHA that is no longer the PR head.
# Page on every terminal RED state that leaves the PR stuck — failure AND
# timed_out (ci.yml has timeout-minutes jobs) — but NOT cancelled: a force-push
# of the rolling branch routinely cancels the prior in-flight run, so alerting on
# cancelled would page on every normal refresh.
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 }}
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"⚠️ 插件预览 manifest PR #{os.environ['NUM']} CI {os.environ.get('CONCLUSION','failed')}(目标 main)\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