@@ -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
2642on :
2743 repository_dispatch :
3248
3349permissions :
3450 contents : write
51+ pull-requests : write
3552
3653concurrency :
3754 group : scoop-autoupdate
@@ -40,30 +57,28 @@ concurrency:
4057jobs :
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