ReviewOS

also looking at this

pantry-pm/pantry

fix(ci): bot commits land through a checked pull request, not a bypass

#274
Merged glennmichael123 wants to merge fix/bot-pushes-via-pr into main
10 files +621 -23

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/actions/merge-via-pr/action.ymladded+154-0
Changes to .github/actions/merge-via-pr/action.yml
@@ -0,0 +1,154 @@
1name: Merge via PR
2description: >-
3 Land an already-committed change on the default branch through a pull request
4 that actually runs the required checks.
5
6inputs:
7 token:
8 description: "GitHub token with contents, pull-requests and actions write."
9 required: true
10 branch-prefix:
11 description: "Branch namespace for the pull request's head."
12 required: false
13 default: 'chore/automated'
14 title:
15 description: "Pull request title. Defaults to the commit subject."
16 required: false
17 default: ''
18 body:
19 description: "Pull request body."
20 required: false
21 default: ''
22 checks:
23 description: "Comma-separated required check names to wait for."
24 required: false
25 default: 'lint,typecheck,test,zig-tests'
26 workflow:
27 description: "Workflow dispatched to produce those checks."
28 required: false
29 default: 'ci.yml'
30 timeout-minutes:
31 description: "How long to wait for the checks before giving up."
32 required: false
33 default: '40'
34
35outputs:
36 merged:
37 description: "true when the pull request was merged."
38 value: ${{ steps.land.outputs.merged }}
39 pr-number:
40 description: "Number of the pull request that was opened."
41 value: ${{ steps.land.outputs.pr-number }}
42 pr-url:
43 description: "URL of the pull request that was opened."
44 value: ${{ steps.land.outputs.pr-url }}
45
46runs:
47 using: composite
48 steps:
49 # A bot cannot push to the default branch: the branch ruleset requires the
50 # CI contexts, and `github-actions[bot]` holds no repository role, so its
51 # push is refused with GH013. Going through a pull request is the honest
52 # answer — the generated catalog gets checked like anything else — but it
53 # needs one piece of care. GitHub suppresses events caused by GITHUB_TOKEN,
54 # which covers a pull request opened with it just as it covers a push, so
55 # the `pull_request` trigger never fires and nothing would ever report
56 # against the required contexts. Dispatching CI at the head branch does
57 # report: a check run is attached to a commit, and that commit is the one
58 # the pull request is gated on.
59 - name: Land the commit through a pull request
60 id: land
61 shell: bash
62 env:
63 GH_TOKEN: ${{ inputs.token }}
64 REPO: ${{ github.repository }}
65 BASE: ${{ github.event.repository.default_branch || 'main' }}
66 PREFIX: ${{ inputs.branch-prefix }}
67 TITLE_IN: ${{ inputs.title }}
68 BODY_IN: ${{ inputs.body }}
69 CHECKS: ${{ inputs.checks }}
70 WORKFLOW: ${{ inputs.workflow }}
71 TIMEOUT_MIN: ${{ inputs.timeout-minutes }}
72 RUN_ID: ${{ github.run_id }}
73 RUN_ATTEMPT: ${{ github.run_attempt }}
74 run: |
75 set -uo pipefail
76
77 BRANCH="${PREFIX}/${RUN_ID}-${RUN_ATTEMPT}"
78 SHA="$(git rev-parse HEAD)"
79 TITLE="${TITLE_IN:-$(git log -1 --pretty=%s)}"
80 echo "merged=false" >> "$GITHUB_OUTPUT"
81
82 # Each run's commit regenerates the whole of what the last one wrote, so
83 # an older open pull request from this same prefix is superseded rather
84 # than merely stale. Closing it keeps a red or slow run from piling up a
85 # queue of pull requests that all say the same thing.
86 gh pr list --repo "$REPO" --state open --json number,headRefName \
87 --jq ".[] | select(.headRefName | startswith(\"${PREFIX}/\")) | .number" 2>/dev/null \
88 | while read -r stale; do
89 [ -n "$stale" ] || continue
90 echo "Superseding #${stale}"
91 gh pr close "$stale" --repo "$REPO" --delete-branch \
92 --comment "Superseded by a newer automated run." || true
93 done
94
95 if ! git push origin "HEAD:refs/heads/${BRANCH}"; then
96 echo "::error::could not push ${BRANCH}"
97 exit 1
98 fi
99
100 PR_URL="$(gh pr create --repo "$REPO" --base "$BASE" --head "$BRANCH" \
101 --title "$TITLE" --body "${BODY_IN:-Opened automatically so the required checks run on generated output.}")"
102 if [ -z "$PR_URL" ]; then
103 echo "::error::could not open a pull request for ${BRANCH}"
104 git push origin --delete "$BRANCH" || true
105 exit 1
106 fi
107 PR_NUMBER="${PR_URL##*/}"
108 echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
109 echo "pr-url=${PR_URL}" >> "$GITHUB_OUTPUT"
110 echo "Opened ${PR_URL}"
111
112 if ! gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$BRANCH"; then
113 echo "::error::could not dispatch ${WORKFLOW} at ${BRANCH}; nothing would report on ${SHA}"
114 exit 1
115 fi
116
117 IFS=',' read -ra WANTED <<< "$CHECKS"
118 DEADLINE=$(( $(date +%s) + TIMEOUT_MIN * 60 ))
119 while :; do
120 if [ "$(date +%s)" -ge "$DEADLINE" ]; then
121 echo "::error::${CHECKS} did not all report on ${SHA} within ${TIMEOUT_MIN}m; ${PR_URL} is left open"
122 exit 1
123 fi
124
125 RUNS="$(gh api "repos/${REPO}/commits/${SHA}/check-runs?per_page=100" \
126 --jq '.check_runs[] | "\(.name)\t\(.status)\t\(.conclusion // "")"' 2>/dev/null)"
127
128 PASSED=0
129 FAILED=''
130 for want in "${WANTED[@]}"; do
131 line="$(printf '%s\n' "$RUNS" | awk -F'\t' -v n="$want" '$1 == n { print; exit }')"
132 [ -n "$line" ] || continue
133 status="$(printf '%s' "$line" | cut -f2)"
134 [ "$status" = "completed" ] || continue
135 case "$(printf '%s' "$line" | cut -f3)" in
136 success|skipped|neutral) PASSED=$((PASSED + 1)) ;;
137 *) FAILED="${FAILED:+$FAILED, }${want}" ;;
138 esac
139 done
140
141 if [ -n "$FAILED" ]; then
142 echo "::error::required checks failed on generated output: ${FAILED}. ${PR_URL} is left open to show why."
143 exit 1
144 fi
145 [ "$PASSED" -eq "${#WANTED[@]}" ] && break
146 sleep 20
147 done
148
149 if ! gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch; then
150 echo "::error:__stx_checks="eyJzdGF0ZSI6Im5ldXRyYWwiLCJjb3VudHMiOnsiZmFpbHVyZSI6MCwicGVuZGluZyI6MCwic3VjY2VzcyI6MCwibmV1dHJhbCI6MH0sImVudHJpZXMiOltdLCJyZXF1aXJlZCI6eyJzYXRpc2ZpZWQiOnRydWUsIm1pc3NpbmciOltdLCJmYWlsaW5nIjpbXSwicGVuZGluZyI6W119LCJhbm5vdGF0aW9ucyI6W119" passed but ${PR_URL} would not merge; it is left open"
151 exit 1
152 fi
153 echo "merged=true" >> "$GITHUB_OUTPUT"
154 echo "Merged ${PR_URL}"
.github/workflows/check-desktop-updates.ymlmodified+15-14
Changes to .github/workflows/check-desktop-updates.yml
@@ -142,6 +142,10 @@ jobs:
142142 # the commit — the gate cannot let a stale manifest through.
143143 record:
144144 needs: [publish-ubuntu, publish-macos]
145 permissions:
146 contents: write
147 actions: write
148 pull-requests: write
145149 if: >-
146150 always() &&
147151 needs.publish-ubuntu.result == 'success' &&
@@ -160,17 +164,14 @@ jobs:
160164 - name: Record verified registry state
161165 working-directory: packages/ts-pantry
162166 run: bun scripts/check-desktop-updates.ts --require-current --commit
163 - name: Push verified manifest commit
164 working-directory: packages/ts-pantry
165 run: |
166 for attempt in 1 2 3 4 5; do
167 if git push; then
168 echo "push ok (attempt $attempt)"
169 exit 0
170 fi
171 echo "push rejected (attempt $attempt); rebasing onto latest main"
172 git fetch origin main
173 git rebase origin/main
174 done
175 echo "ERROR: could not push manifest after 5 attempts" >&2
176 exit 1
167 # The rebase-and-retry loop this replaces answered a race with another
168 # push. It cannot answer the ruleset: main requires the CI contexts, a
169 # bot push holds no repository role to be excused by, and retrying a
170 # GH013 five times only rebases five times before failing anyway.
171 - name: Land the verified manifest through a pull request
172 uses: ./.github/actions/merge-via-pr
173 with:
174 token: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
175 branch-prefix: chore/desktop-manifest
176 body: |
177 Verified desktop manifest state from `check-desktop-updates.yml`.
.github/workflows/update-packages.ymlmodified+27-5
Changes to .github/workflows/update-packages.yml
@@ -53,10 +53,12 @@ jobs:
5353 update-packages:
5454 name: Update packages and index
5555 runs-on: ubuntu-latest
56 timeout-minutes: 30
56 # Covers the sweep plus the wait for CI to report on the pull request.
57 timeout-minutes: 80
5758 permissions:
5859 contents: write
5960 actions: write
61 pull-requests: write
6062
6163 steps:
6264 - name: Checkout repository
@@ -221,7 +223,7 @@ jobs:
221223 fi
222224 echo "safe_to_commit=true" >> $GITHUB_OUTPUT
223225
224 - name: Commit and push changes
226 - name: Commit changes
225227 id: commit
226228 if: steps.git-check.outputs.changes == 'true' && steps.deletion-check.outputs.safe_to_commit == 'true'
227229 run: |
@@ -234,12 +236,32 @@ jobs:
234236 test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
235237 git add packages/ts-pantry/src/packages/ packages/ts-pantry/src/generated-package-names.ts packages/zig/src/packages/generated.zig packages/action/dist/index.js packages/action/dist/post.js
236238 git commit -F /tmp/pantry-update-commit-message.txt
237 git push origin HEAD:refs/heads/main
238239
239 echo "committed=true" >> $GITHUB_OUTPUT
240 # This commit used to go straight to main. It cannot any more: main
241 # requires the CI contexts, and a bot push carries no repository role to
242 # be excused by, so the push comes back GH013. The alternative to a pull
243 # request was excusing the Actions app from the checks, which would have
244 # exempted every workflow that ever pushes rather than this one, and
245 # would have kept the generated catalog the only thing on main nobody
246 # checks. So it is checked now, like everything else.
247 - name: Land the update through a pull request
248 id: land
249 if: steps.commit.conclusion == 'success'
250 uses: ./.github/actions/merge-via-pr
251 with:
252 token: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
253 branch-prefix: chore/package-updates
254 body: |
255 Automated catalog refresh from `update-packages.yml`.
256
257 Opened rather than pushed because `main` requires the CI contexts
258 and a bot push holds no role to be excused by. CI is dispatched at
259 this branch explicitly: a pull request opened with `GITHUB_TOKEN`
260 does not fire `pull_request`, so without the dispatch nothing would
261 ever report here.
240262
241263 - name: Publish updated packages to the registry
242 if: steps.commit.outputs.committed == 'true'
264 if: steps.land.outputs.merged == 'true'
243265 env:
244266 GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
245267 run: |