Skip to content

Commit 8c44c92

Browse files
bpamiriclaude
andauthored
feat: convert autoupdate to PR + auto-merge model (#8)
Mirrors wheels-dev/homebrew-wheels' auto-update.yml pattern. Today's direct-push flow (auto-pr.ps1 -Push) lands manifest updates on main in ~1 min but leaves no PR audit trail; the new flow opens a PR per release and squash-merges it after CI is CLEAN, taking ~2-3 min. New flow: 1. scoop checkver.ps1 -Update writes the manifest in place — no git operations, scoop's responsibility ends at the working tree. 2. A new "Detect manifest changes" step runs `git status` against ./bucket and computes branch + PR title from the dispatch payload (or run number, for cron/manual runs). It validates the dispatch version string against a SemVer-ish charset before letting it shape a branch name or commit message. 3. The PR step creates the branch, commits via git config user 'github-actions[bot]', pushes, and opens the PR via gh. 4. The "Wait for checks and merge" step synchronously polls the PR's mergeStateStatus until CLEAN, then squash-merges. The concurrency lock is held through the merge so back-to-back dispatches don't fork off a stale base — same reasoning as wheels-dev/homebrew-wheels' auto-update.yml. Hub is no longer needed: gh CLI (preinstalled on the runner) handles PR creation + merging, and the manifest update no longer goes through auto-pr.ps1's git wrapper. Drop the `scoop install hub` step. Security: every value from github.event.client_payload.* flows through env: bindings before reaching any shell. The dispatch version is additionally regex-validated against ^[A-Za-z0-9_.+-]{1,64}$ before it shapes a branch / commit / PR title, so a malicious upstream dispatch can't inject shell metacharacters. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4dc0198 commit 8c44c92

1 file changed

Lines changed: 200 additions & 30 deletions

File tree

.github/workflows/autoupdate.yml

Lines changed: 200 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,20 @@ name: Autoupdate Scoop manifests
33
# Refresh wheels.json (stable) and wheels-be.json (bleeding-edge) when the
44
# upstream wheels-dev/wheels release workflow tags a new version.
55
#
6+
# Flow (PR + auto-merge model, mirrors wheels-dev/homebrew-wheels):
7+
# 1. scoop checkver -Update rewrites the manifest in place (no git ops).
8+
# 2. If anything changed, we create a feature branch, commit, push, and
9+
# open a PR via `gh pr create`.
10+
# 3. A synchronous wait-and-merge loop polls the PR's mergeStateStatus.
11+
# Once CLEAN, we `gh pr merge --squash --delete-branch` and exit.
12+
# 4. The concurrency lock is held through the merge so the next queued
13+
# run forks off a clean, already-bumped base.
14+
#
615
# Three triggers:
716
# - repository_dispatch (wheels-released): fired by wheels-dev/wheels
8-
# release.yml on every published release. ~5-7 min end-to-end from
9-
# upstream tag → manifest commit here. Payload includes 'channel'
10-
# so we only refresh the affected manifest.
17+
# release.yml on every published release. End-to-end ~2-3 min from
18+
# upstream tag → PR merge here. Payload includes 'channel' so we
19+
# only refresh the affected manifest.
1120
# - schedule: daily cron at 08:30 UTC. Catches missed dispatches
1221
# (token expiry, network blip) without waiting for the next release.
1322
# - workflow_dispatch: manual one-off for debugging.
@@ -18,10 +27,17 @@ name: Autoupdate Scoop manifests
1827
# must actually bump the manifest — skipping intermediate snapshots in a
1928
# burst still leaves the bucket at the wrong version.
2029
#
21-
# Auth: AUTO_MERGE_PAT is a fine-grained PAT with Contents:write on this
22-
# repo. Used by the checkout step + the push step so the push event can
23-
# trigger any downstream workflows. Falls back to GITHUB_TOKEN if the
24-
# secret isn't set (push still works, just won't fire follow-on events).
30+
# Auth: AUTO_MERGE_PAT is a fine-grained PAT with Contents:write +
31+
# Pull-requests:write on this repo. Used for the checkout (so the push
32+
# event triggers any downstream workflows), the push, the PR creation,
33+
# and the merge. Falls back to GITHUB_TOKEN if the secret isn't set —
34+
# push still works but auto-merge may be gated by the default token's
35+
# downstream-event restriction.
36+
#
37+
# Security note: every value from github.event.client_payload.* flows
38+
# through env: bindings before reaching any run script — never inlined
39+
# via ${{ }} interpolation in a shell command. See the security guide:
40+
# https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/
2541

2642
on:
2743
repository_dispatch:
@@ -32,6 +48,7 @@ on:
3248

3349
permissions:
3450
contents: write
51+
pull-requests: write
3552

3653
concurrency:
3754
group: scoop-autoupdate
@@ -40,30 +57,28 @@ concurrency:
4057
jobs:
4158
autoupdate:
4259
runs-on: windows-latest
43-
timeout-minutes: 15
60+
timeout-minutes: 20
4461
steps:
4562
- name: Checkout bucket
4663
uses: actions/checkout@v4
4764
with:
4865
token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }}
4966
fetch-depth: 0
5067

51-
- name: Install Scoop + hub
68+
- name: Install Scoop
5269
shell: pwsh
5370
run: |
5471
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force
5572
Invoke-RestMethod -Uri https://get.scoop.sh -OutFile install.ps1
5673
.\install.ps1 -RunAsAdmin
57-
# Make scoop visible in subsequent steps
5874
$scoopShim = "$env:USERPROFILE\scoop\shims"
5975
echo "$scoopShim" | Out-File -FilePath $env:GITHUB_PATH -Append
6076
echo "SCOOP_HOME=$(scoop prefix scoop)" | Out-File -FilePath $env:GITHUB_ENV -Append
61-
# Scoop's auto-pr.ps1 hard-requires `hub` even in -Push mode — it
62-
# uses hub as a wrapper around git for everything (checkout, add,
63-
# commit, push). Without this install, auto-pr.ps1 exits 1 with
64-
# "Please install hub 'scoop install hub'" before doing anything.
65-
scoop install hub
66-
hub --version
77+
# `hub` is no longer required — we use checkver.ps1 directly (no
78+
# git ops in scoop) plus `gh` (preinstalled on the runner) for
79+
# the PR + merge steps. Previous direct-push flow needed it
80+
# because auto-pr.ps1 -Push wrapped git via hub; we don't call
81+
# auto-pr.ps1 anymore.
6782
6883
- name: Decide which manifests to refresh
6984
id: route
@@ -98,31 +113,186 @@ jobs:
98113
"app=$app" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
99114
"skip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
100115
101-
- name: Run Scoop checkver + autoupdate
116+
- name: Run Scoop checkver (update manifests in-place, no git)
102117
if: steps.route.outputs.skip != 'true'
103118
shell: pwsh
104119
env:
105120
APP: ${{ steps.route.outputs.app }}
106121
run: |
107122
$ErrorActionPreference = 'Stop'
108-
$auto = Join-Path $env:SCOOP_HOME 'bin\auto-pr.ps1'
109-
if (-not (Test-Path $auto)) {
110-
throw "Cannot find auto-pr.ps1 at $auto"
123+
$checkver = Join-Path $env:SCOOP_HOME 'bin\checkver.ps1'
124+
if (-not (Test-Path $checkver)) {
125+
throw "Cannot find checkver.ps1 at $checkver"
126+
}
127+
# -Update writes the manifest in place if the upstream version
128+
# changed; -SkipUpdated suppresses the "no update needed" lines.
129+
# No git operations — the PR creation runs as a separate step
130+
# below so we have full control over the branch / commit / PR
131+
# metadata.
132+
& $checkver -App $env:APP -Dir './bucket' -Update -SkipUpdated
133+
134+
- name: Detect manifest changes and compute PR metadata
135+
id: changes
136+
if: steps.route.outputs.skip != 'true'
137+
shell: pwsh
138+
env:
139+
DISPATCH_VERSION: ${{ github.event.client_payload.version }}
140+
DISPATCH_CHANNEL: ${{ github.event.client_payload.channel }}
141+
run: |
142+
$ErrorActionPreference = 'Stop'
143+
$changed = (git status --porcelain ./bucket) -split "`n" | Where-Object { $_ -ne '' }
144+
if (-not $changed -or $changed.Count -eq 0) {
145+
Write-Host "No manifest changes detected; skipping PR."
146+
"has_changes=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
147+
exit 0
111148
}
112149
150+
Write-Host "Changes detected:"
151+
$changed | ForEach-Object { Write-Host " $_" }
152+
153+
$event = "${env:GITHUB_EVENT_NAME}"
154+
$version = "${env:DISPATCH_VERSION}"
155+
$channel = "${env:DISPATCH_CHANNEL}"
156+
$runNumber = "${env:GITHUB_RUN_NUMBER}"
157+
158+
# Validate the version string before letting it shape a branch
159+
# name or commit message — it originates from a repository_dispatch
160+
# payload (untrusted). Allow only SemVer-ish characters.
161+
if ($version -and $version -notmatch '^[A-Za-z0-9_.+\-]{1,64}$') {
162+
Write-Warning "Rejecting suspicious version string: '$version'"
163+
$version = ''
164+
}
165+
166+
# Branch names can't contain '+' (URL-encoded as %2B and breaks
167+
# `gh pr create --head`). SemVer build metadata uses '+' so we
168+
# normalize to '-' for the branch only — the manifest keeps the
169+
# canonical version string.
170+
$safeVer = $version -replace '\+', '-'
171+
172+
if ($event -eq 'repository_dispatch' -and $version) {
173+
if ($channel -eq 'stable') {
174+
$title = "wheels: update to $version"
175+
$branch = "auto-update/wheels-$safeVer"
176+
} elseif ($channel -eq 'bleeding-edge') {
177+
$title = "wheels-be: update to $version"
178+
$branch = "auto-update/wheels-be-$safeVer"
179+
} else {
180+
$title = "chore: refresh manifests ($channel $version)"
181+
$branch = "auto-update/run-$runNumber"
182+
}
183+
} else {
184+
$title = "chore: scheduled manifest refresh (run $runNumber)"
185+
$branch = "auto-update/cron-$runNumber"
186+
}
187+
188+
"has_changes=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
189+
"branch=$branch" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
190+
"title=$title" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
191+
Write-Host "Branch: $branch"
192+
Write-Host "Title: $title"
193+
194+
- name: Create branch, commit, push, open PR
195+
id: pr
196+
if: steps.changes.outputs.has_changes == 'true'
197+
shell: pwsh
198+
env:
199+
GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }}
200+
BRANCH: ${{ steps.changes.outputs.branch }}
201+
TITLE: ${{ steps.changes.outputs.title }}
202+
run: |
203+
$ErrorActionPreference = 'Stop'
113204
git config user.name 'github-actions[bot]'
114205
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
115206
116-
# -Push commits directly to the origin branch (no PR). -Upstream
117-
# is required by the script's param validator but only used in
118-
# -Request mode; -Push ignores it.
119-
& $auto `
120-
-Push `
121-
-Upstream 'wheels-dev/scoop-wheels:main' `
122-
-OriginBranch 'main' `
123-
-App $env:APP `
124-
-Dir './bucket' `
125-
-SkipUpdated
207+
git checkout -b $env:BRANCH
208+
git add ./bucket
209+
git commit -m $env:TITLE
210+
git push origin $env:BRANCH
211+
212+
$runUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID"
213+
$eventName = "$env:GITHUB_EVENT_NAME"
214+
$body = @(
215+
"Auto-updated by ``scoop checkver`` from the upstream wheels-dev/wheels release.",
216+
"",
217+
"This PR was opened by the autoupdate.yml workflow on ``$eventName``. It will auto-merge once any required CI checks pass.",
218+
"",
219+
"- Triggered by: ``$eventName``",
220+
"- Workflow run: $runUrl"
221+
) -join "`n"
222+
223+
gh pr create `
224+
--base main `
225+
--head $env:BRANCH `
226+
--title $env:TITLE `
227+
--body $body | Out-Null
228+
229+
# Look the PR up by branch — more reliable than parsing the URL
230+
# gh pr create prints to stdout.
231+
$prNumber = (gh pr list --head $env:BRANCH --base main --json number --jq '.[0].number')
232+
if (-not $prNumber) {
233+
throw "Could not resolve PR number for branch $env:BRANCH"
234+
}
235+
Write-Host "Opened PR #$prNumber"
236+
"pr_number=$prNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
237+
238+
# Synchronous wait-and-merge — mirrors wheels-dev/homebrew-wheels'
239+
# auto-update.yml pattern. The reason we don't use `gh pr merge --auto`:
240+
# --auto releases the workflow's concurrency lock before the merge
241+
# actually commits, allowing the next queued auto-update run to fork
242+
# off a stale base. Holding the slot through the merge means each
243+
# subsequent run sees an already-bumped main and produces a clean PR
244+
# with no conflicts.
245+
#
246+
# Polls every 15s, caps total wait at 10 min. CLEAN → squash-merge.
247+
# BLOCKED/UNSTABLE → required check still running, keep waiting.
248+
# DIRTY → unexpected (concurrency group should prevent it); fail
249+
# loudly. BEHIND → upstream moved; let GitHub auto-update via --auto.
250+
- name: Wait for checks and merge
251+
if: steps.changes.outputs.has_changes == 'true'
252+
shell: pwsh
253+
env:
254+
GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }}
255+
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
256+
run: |
257+
$ErrorActionPreference = 'Stop'
258+
$MAX_WAIT = 600
259+
$INTERVAL = 15
260+
$elapsed = 0
261+
$state = ''
262+
263+
while ($elapsed -lt $MAX_WAIT) {
264+
$state = (gh pr view $env:PR_NUMBER --json mergeStateStatus --jq '.mergeStateStatus').Trim()
265+
switch ($state) {
266+
'CLEAN' {
267+
Write-Host "PR #$env:PR_NUMBER is mergeable. Squash-merging..."
268+
gh pr merge $env:PR_NUMBER --squash --delete-branch
269+
Write-Host "PR #$env:PR_NUMBER merged."
270+
exit 0
271+
}
272+
{ $_ -in 'BLOCKED','UNSTABLE' } {
273+
Write-Host "PR #$env:PR_NUMBER state=$state; waiting (${elapsed}s elapsed)..."
274+
Start-Sleep -Seconds $INTERVAL
275+
$elapsed += $INTERVAL
276+
}
277+
'DIRTY' {
278+
Write-Error "PR #$env:PR_NUMBER is in DIRTY state — concurrency lock failed to prevent a conflict."
279+
exit 1
280+
}
281+
'BEHIND' {
282+
Write-Host "PR #$env:PR_NUMBER is BEHIND base — switching to --auto so GitHub rebases and merges."
283+
gh pr merge $env:PR_NUMBER --squash --delete-branch --auto
284+
exit 0
285+
}
286+
default {
287+
Write-Host "Unknown mergeStateStatus '$state'; waiting..."
288+
Start-Sleep -Seconds $INTERVAL
289+
$elapsed += $INTERVAL
290+
}
291+
}
292+
}
293+
294+
Write-Error "PR #$env:PR_NUMBER did not become mergeable within $MAX_WAIT seconds (last state: $state)"
295+
exit 1
126296
127297
- name: Report
128298
if: always()

0 commit comments

Comments
 (0)