/
/
/
1# Auto approve and merge dependency update PRs
2# for the frontend and models packages.
3
4name: Auto-merge dependency updates
5
6on:
7 pull_request_target:
8 types: [opened, synchronize, reopened]
9 branches:
10 - dev
11 push:
12 branches:
13 - dev
14 # Manual escape hatch to drain a stranded bump PR without a human merge commit
15 workflow_dispatch:
16
17env:
18 EXPECTED_APP_BOT_LOGIN: musicassistant-bot[bot]
19 EXPECTED_APP_BOT_LOGIN_ENCODED: musicassistant-bot%5Bbot%5D
20 EXPECTED_APP_BOT_ID: "304008617"
21 EXPECTED_APP_SLUG: musicassistant-bot
22 EXPECTED_APP_INSTALLATION_ID: "146062122"
23
24# CRITICAL SECURITY: This workflow uses pull_request_target which runs in the context
25# of the base repository and has access to secrets. Multiple security checks ensure
26# only trusted automation PRs are auto-merged.
27
28jobs:
29 auto-merge:
30 name: Auto-approve and merge
31 runs-on: ubuntu-latest
32 # Only run if branch name matches the expected pattern
33 if: |
34 github.event_name == 'pull_request_target' && (
35 startsWith(github.event.pull_request.head.ref, 'auto-update-frontend-') ||
36 startsWith(github.event.pull_request.head.ref, 'auto-update-models-')
37 )
38
39 permissions:
40 contents: write
41 pull-requests: write
42
43 steps:
44 # Security check 1: Verify PR is from the exact expected GitHub App bot
45 - name: Verify PR is from trusted source
46 id: verify_pr_author
47 run: |
48 # GitHub App bots are not collaborators. Trust only the known App account
49 # when the event also proves it is a same-repository Bot PR.
50 if [ "$PR_AUTHOR" != "$EXPECTED_APP_BOT_LOGIN" ] || \
51 [ "$PR_AUTHOR_TYPE" != "Bot" ] || \
52 [ "$PR_AUTHOR_ID" != "$EXPECTED_APP_BOT_ID" ] || \
53 [ "$HEAD_REPOSITORY" != "$BASE_REPOSITORY" ]; then
54 echo "â GitHub App PR identity does not match the trusted source"
55 exit 1
56 fi
57
58 if ! BOT_PROFILE=$(gh api "/users/$EXPECTED_APP_BOT_LOGIN_ENCODED" 2>/dev/null); then
59 echo "â Could not verify the GitHub App bot profile"
60 exit 1
61 fi
62 if [ "$(jq -r '.login' <<< "$BOT_PROFILE")" != "$EXPECTED_APP_BOT_LOGIN" ] || \
63 [ "$(jq -r '.type' <<< "$BOT_PROFILE")" != "Bot" ] || \
64 [ "$(jq -r '.id' <<< "$BOT_PROFILE")" != "$EXPECTED_APP_BOT_ID" ]; then
65 echo "â GitHub App bot profile does not match the trusted identity"
66 exit 1
67 fi
68 echo "â
PR is from the trusted GitHub App bot"
69 env:
70 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
71 BASE_REPOSITORY: ${{ github.repository }}
72 HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
73 PR_AUTHOR: ${{ github.event.pull_request.user.login }}
74 PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
75 PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
76
77 # Security check 2: Verify PR labels and source branch
78 - name: Verify PR labels and source
79 run: |
80 LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
81
82 if [[ "$LABELS" != *"dependencies"* ]]; then
83 echo "â PR does not have 'dependencies' label"
84 exit 1
85 fi
86
87 if [[ "$BRANCH" != auto-update-frontend-* && "$BRANCH" != auto-update-models-* ]]; then
88 echo "â Branch name does not match expected pattern: $BRANCH"
89 exit 1
90 fi
91
92 echo "â
PR has 'dependencies' label and valid branch name"
93
94 env:
95 BRANCH: ${{ github.event.pull_request.head.ref }}
96
97 # NOTE: The PR is intentionally never checked out. All validation below
98 # uses the GitHub API, so no untrusted code ever reaches this privileged
99 # workflow's filesystem.
100
101 - name: Get PR details
102 id: pr
103 run: |
104 echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
105
106 # Security check 4: Verify every commit is authored by the expected GitHub App bot
107 - name: Verify commit authors
108 run: |
109 # A dependency bump PR only ever needs a handful of commits
110 COMMIT_COUNT="${{ github.event.pull_request.commits }}"
111 if [ "$COMMIT_COUNT" -gt 20 ]; then
112 echo "â PR has $COMMIT_COUNT commits, too many for a dependency update"
113 exit 1
114 fi
115
116 COMMITS=$(gh api "/repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/commits" --paginate --jq '.[]' | jq -s '.')
117
118 # Guard against API truncation: we must have seen every commit
119 FETCHED_COUNT=$(echo "$COMMITS" | jq 'length')
120 if [ "$FETCHED_COUNT" -ne "$COMMIT_COUNT" ]; then
121 echo "â Fetched $FETCHED_COUNT commits but the PR reports $COMMIT_COUNT"
122 exit 1
123 fi
124
125 # Commits whose author email is not linked to a GitHub account have
126 # no login to verify, so they must be rejected
127 UNATTRIBUTED=$(echo "$COMMITS" | jq '[.[] | select(.author.login == null)] | length')
128 if [ "$UNATTRIBUTED" -gt 0 ]; then
129 echo "â $UNATTRIBUTED commit(s) have no linked GitHub author"
130 exit 1
131 fi
132
133 UNTRUSTED_AUTHORS=$(echo "$COMMITS" | jq \
134 --arg login "$EXPECTED_APP_BOT_LOGIN" \
135 --argjson id "$EXPECTED_APP_BOT_ID" \
136 '[.[] | select(
137 .author.login != $login or
138 .author.type != "Bot" or
139 .author.id != $id
140 )] | length')
141 if [ "$UNTRUSTED_AUTHORS" -gt 0 ]; then
142 echo "â $UNTRUSTED_AUTHORS commit(s) are not authored by the trusted GitHub App bot"
143 exit 1
144 fi
145
146 echo "â
All commits are authored by the trusted GitHub App bot"
147
148 env:
149 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
150
151 # Security check 5: Verify only dependency files were changed
152 - name: Verify only dependency files were changed
153 run: |
154 # Only pyproject.toml and requirements_all.txt should be modified
155 CHANGED_FILES=$(gh api "/repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/files" --paginate --jq '.[].filename')
156
157 if [[ -z "$CHANGED_FILES" ]]; then
158 echo "â Could not determine changed files"
159 exit 1
160 fi
161
162 echo "Changed files:"
163 echo "$CHANGED_FILES"
164
165 for file in $CHANGED_FILES; do
166 if [[ "$file" != "pyproject.toml" ]] && [[ "$file" != "requirements_all.txt" ]]; then
167 echo "â Unexpected file changed: $file"
168 echo "Only pyproject.toml and requirements_all.txt should be modified"
169 exit 1
170 fi
171 done
172
173 echo "â
Only expected dependency files were changed"
174 env:
175 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
176
177 # Security check 6: Verify changes are only version bumps
178 - name: Verify changes are version bumps
179 run: |
180 DIFF=$(gh pr diff "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}")
181
182 # Every added/removed line (excluding file headers) must be a version
183 # pin of an allowed package, in either pyproject.toml or
184 # requirements_all.txt format.
185 UNEXPECTED=$(echo "$DIFF" \
186 | grep -E '^[+-]' \
187 | grep -vE '^(\+\+\+|---)' \
188 | grep -vE '^[+-][[:space:]]*"?music-assistant-(frontend|models)==[0-9][0-9a-zA-Z.]*"?,?[[:space:]]*$' \
189 || true)
190
191 if [[ -n "$UNEXPECTED" ]]; then
192 echo "â Diff contains changes that are not version bumps:"
193 echo "$UNEXPECTED"
194 exit 1
195 fi
196
197 # A pin must be added, not just removed
198 if ! echo "$DIFF" | grep -qE '^\+.*music-assistant-(frontend|models)=='; then
199 echo "â No added version pin found"
200 exit 1
201 fi
202
203 echo "â
Changes are version bumps"
204 env:
205 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
206
207 # Security check 7: Wait for package to be available on PyPI
208 - name: Wait for package availability on PyPI
209 run: |
210 # Extract the package name and version from the added lines of the diff
211 DIFF=$(gh pr diff "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" | grep '^+' || true)
212
213 if echo "$DIFF" | grep -q "music-assistant-frontend=="; then
214 PACKAGE="music-assistant-frontend"
215 VERSION=$(echo "$DIFF" | grep -oP 'music-assistant-frontend==\K[0-9.]+' | head -1)
216 elif echo "$DIFF" | grep -q "music-assistant-models=="; then
217 PACKAGE="music-assistant-models"
218 VERSION=$(echo "$DIFF" | grep -oP 'music-assistant-models==\K[0-9.]+' | head -1)
219 else
220 echo "â Could not determine package name and version"
221 exit 1
222 fi
223
224 echo "Waiting for $PACKAGE version $VERSION to be available on PyPI..."
225
226 # Retry for up to 20 minutes (20 attempts with 60 second intervals)
227 MAX_ATTEMPTS=20
228 SLEEP_DURATION=60
229 ATTEMPT=1
230
231 while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
232 echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Checking if $PACKAGE==$VERSION is available..."
233
234 # Try to get package info from PyPI JSON API
235 HTTP_CODE=$(curl -s -o /tmp/pypi_response.json -w "%{http_code}" "https://pypi.org/pypi/$PACKAGE/json")
236
237 if [ "$HTTP_CODE" -eq 200 ]; then
238 # Check if the specific version exists
239 if grep -q "\"$VERSION\"" /tmp/pypi_response.json; then
240 echo "â
Package $PACKAGE version $VERSION is available on PyPI"
241
242 # Additional verification: try to download the package
243 if python3 -m pip download --no-deps "$PACKAGE==$VERSION" > /dev/null 2>&1; then
244 echo "â
Package $PACKAGE==$VERSION can be installed"
245 exit 0
246 else
247 echo "â ï¸ Package found in PyPI API but pip download failed, retrying..."
248 fi
249 else
250 echo "â¹ï¸ Package $PACKAGE exists but version $VERSION not yet available"
251 fi
252 else
253 echo "â¹ï¸ HTTP $HTTP_CODE when accessing PyPI API"
254 fi
255
256 if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
257 echo "Waiting ${SLEEP_DURATION}s before retry..."
258 sleep $SLEEP_DURATION
259 fi
260
261 ATTEMPT=$((ATTEMPT + 1))
262 done
263
264 echo "â Package $PACKAGE version $VERSION did not become available within the timeout period"
265 echo "This might indicate:"
266 echo " - The package was not published to PyPI"
267 echo " - PyPI is experiencing delays"
268 echo " - The version number in the PR is incorrect"
269 exit 1
270 env:
271 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
272
273 # All security checks passed - approve the PR
274 - name: Auto-approve PR
275 run: |
276 gh pr review "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" --approve --body "â
Automated dependency update - all security checks passed"
277 env:
278 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
279
280 # The merge must be enabled with the App token: the merge actor is whoever
281 # enabled auto-merge, and GitHub never triggers workflows for pushes made by
282 # GITHUB_TOKEN, so the discover-stale job below would not run on the merge.
283 - name: Create merge token
284 id: merge_token
285 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
286 with:
287 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
288 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
289 owner: ${{ github.repository_owner }}
290 repositories: ${{ github.event.repository.name }}
291 permission-contents: write
292 permission-pull-requests: write
293
294 - name: Verify GitHub App identity
295 env:
296 APP_SLUG: ${{ steps.merge_token.outputs.app-slug }}
297 INSTALLATION_ID: ${{ steps.merge_token.outputs.installation-id }}
298 run: |
299 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
300 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
301 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
302 exit 1
303 fi
304
305 # Enable auto-merge with squash
306 - name: Enable auto-merge
307 run: |
308 gh pr merge "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" --auto --squash
309 env:
310 GH_TOKEN: ${{ steps.merge_token.outputs.token }}
311
312 - name: Comment on success
313 if: success()
314 run: |
315 gh pr comment "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" --body "ð¤ This PR has been automatically approved and will be merged once all checks pass."
316 env:
317 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
318
319 # Re-cutting raises a synchronize event, which puts the PR back through the job above.
320 discover-stale:
321 name: Find stale dependency PRs
322 if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
323 runs-on: ubuntu-latest
324 permissions:
325 contents: read
326 pull-requests: read
327 outputs:
328 prs: ${{ steps.find.outputs.prs }}
329 any: ${{ steps.find.outputs.any }}
330 steps:
331 - name: List open bump PRs that are behind dev
332 id: find
333 env:
334 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
335 REPO: ${{ github.repository }}
336 run: |
337 # a fork PR with a matching branch name has no such ref here and would 404 the compare
338 CANDIDATES=$(gh pr list -R "$REPO" --base dev --state open --limit 50 \
339 --json number,headRefName,author,isCrossRepository \
340 | jq -c --arg bot "$EXPECTED_APP_BOT_LOGIN" --arg slug "$EXPECTED_APP_SLUG" '[.[]
341 | select(.isCrossRepository | not)
342 # gh renders the App as app/<slug>, the REST API as <slug>[bot]; accept either
343 | select(.author.login == $bot or .author.login == "app/\($slug)")
344 | select(.headRefName | test("^auto-update-(models|frontend)-[0-9][0-9a-zA-Z.]*$"))
345 | .number]')
346
347 SELECTED='[]'
348 for NUMBER in $(echo "$CANDIDATES" | jq -r '.[]'); do
349 BRANCH=$(gh pr view "$NUMBER" -R "$REPO" --json headRefName --jq '.headRefName')
350
351 # the branch can vanish when its PR merges, and a 404 body lands on stdout not stderr
352 BEHIND=$(gh api "/repos/$REPO/compare/dev...$BRANCH" --jq '.behind_by' 2>/dev/null || true)
353 if ! [[ "$BEHIND" =~ ^[0-9]+$ ]] || [ "$BEHIND" -eq 0 ]; then
354 echo "PR #$NUMBER is gone or up to date with dev, skipping"
355 continue
356 fi
357
358 # Re-cutting discards the branch, so leave any PR that carries more than a pin alone.
359 EXTRA=$(gh pr diff "$NUMBER" -R "$REPO" \
360 | grep -E '^[+-]' \
361 | grep -vE '^(\+\+\+|---)' \
362 | grep -vE '^[+-][[:space:]]*"?music-assistant-(frontend|models)==[0-9][0-9a-zA-Z.]*"?,?[[:space:]]*$' \
363 || true)
364 if [ -n "$EXTRA" ]; then
365 echo "PR #$NUMBER carries non-pin changes, skipping:"
366 echo "$EXTRA"
367 continue
368 fi
369
370 echo "PR #$NUMBER ($BRANCH) is $BEHIND commit(s) behind dev, queueing refresh"
371 SELECTED=$(echo "$SELECTED" | jq -c --argjson n "$NUMBER" '. + [$n]')
372 done
373
374 echo "prs=$SELECTED" >> "$GITHUB_OUTPUT"
375 if [ "$(echo "$SELECTED" | jq 'length')" -gt 0 ]; then
376 echo "any=true" >> "$GITHUB_OUTPUT"
377 else
378 echo "any=false" >> "$GITHUB_OUTPUT"
379 fi
380
381 refresh-stale:
382 name: Re-cut PR #${{ matrix.pr }}
383 needs: discover-stale
384 if: needs.discover-stale.outputs.any == 'true'
385 runs-on: ubuntu-latest
386 permissions:
387 contents: read
388 pull-requests: read
389 concurrency:
390 # Serialise per branch so two dev pushes in quick succession cannot race on the same ref.
391 group: refresh-stale-${{ matrix.pr }}
392 cancel-in-progress: false
393 strategy:
394 fail-fast: false
395 matrix:
396 pr: ${{ fromJSON(needs.discover-stale.outputs.prs) }}
397 steps:
398 - name: Read PR metadata
399 id: pr
400 env:
401 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
402 REPO: ${{ github.repository }}
403 NUMBER: ${{ matrix.pr }}
404 run: |
405 gh pr view "$NUMBER" -R "$REPO" --json headRefName,title,body > /tmp/pr.json
406 BRANCH=$(jq -r '.headRefName' /tmp/pr.json)
407 TITLE=$(jq -r '.title' /tmp/pr.json)
408 # via a file: release notes carry backticks and newlines the shell would re-interpret
409 jq -r '.body' /tmp/pr.json > /tmp/body
410
411 if [[ "$BRANCH" == auto-update-models-* ]]; then
412 PACKAGE="music-assistant-models"
413 VERSION="${BRANCH#auto-update-models-}"
414 else
415 PACKAGE="music-assistant-frontend"
416 VERSION="${BRANCH#auto-update-frontend-}"
417 fi
418
419 # the version reaches a sed program below, so it must carry no metacharacters
420 if ! [[ "$VERSION" =~ ^[0-9][0-9a-zA-Z.]*$ ]]; then
421 echo "Refusing to act on malformed version '$VERSION' from branch '$BRANCH'"
422 exit 1
423 fi
424
425 echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
426 echo "package=$PACKAGE" >> "$GITHUB_OUTPUT"
427 echo "version=$VERSION" >> "$GITHUB_OUTPUT"
428 echo "title=$TITLE" >> "$GITHUB_OUTPUT"
429
430 - name: Check out dev
431 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
432 with:
433 ref: dev
434 persist-credentials: false
435
436 - name: Apply the version pin
437 id: pin
438 env:
439 PACKAGE: ${{ steps.pr.outputs.package }}
440 VERSION: ${{ steps.pr.outputs.version }}
441 run: |
442 # in-place edit keeps requirements_all.txt in the order gen_requirements_all produces
443 sed -i.bak "s/$PACKAGE==.*/$PACKAGE==$VERSION\",/" pyproject.toml
444 sed -i.bak "s/$PACKAGE==.*/$PACKAGE==$VERSION/" requirements_all.txt
445 rm -f pyproject.toml.bak requirements_all.txt.bak
446
447 if [ -z "$(git status --porcelain)" ]; then
448 echo "dev already pins $PACKAGE==$VERSION, nothing to re-cut"
449 echo "changed=false" >> "$GITHUB_OUTPUT"
450 exit 0
451 fi
452 echo "changed=true" >> "$GITHUB_OUTPUT"
453
454 # Defense in depth against a sed that matched more than intended.
455 UNEXPECTED=$(git diff -U0 \
456 | grep -E '^[+-]' \
457 | grep -vE '^(\+\+\+|---)' \
458 | grep -vE '^[+-][[:space:]]*"?music-assistant-(frontend|models)==[0-9][0-9a-zA-Z.]*"?,?[[:space:]]*$' \
459 || true)
460 if [ -n "$UNEXPECTED" ]; then
461 echo "Refusing to push, the pin edit touched more than a version:"
462 echo "$UNEXPECTED"
463 exit 1
464 fi
465 git diff --stat
466
467 - name: Create branch token
468 if: steps.pin.outputs.changed == 'true'
469 id: push_token
470 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
471 with:
472 client-id: ${{ vars.MUSIC_ASSISTANT_BOT_CLIENT_ID }}
473 private-key: ${{ secrets.MUSIC_ASSISTANT_BOT_PRIVATE_KEY }}
474 owner: ${{ github.repository_owner }}
475 repositories: ${{ github.event.repository.name }}
476 permission-contents: write
477 permission-pull-requests: write
478
479 - name: Verify GitHub App identity
480 if: steps.pin.outputs.changed == 'true'
481 env:
482 APP_SLUG: ${{ steps.push_token.outputs.app-slug }}
483 INSTALLATION_ID: ${{ steps.push_token.outputs.installation-id }}
484 run: |
485 if [ "$APP_SLUG" != "$EXPECTED_APP_SLUG" ] || \
486 [ "$INSTALLATION_ID" != "$EXPECTED_APP_INSTALLATION_ID" ]; then
487 echo "Unexpected GitHub App installation: $APP_SLUG/$INSTALLATION_ID" >&2
488 exit 1
489 fi
490
491 - name: Re-cut the branch on top of dev
492 if: steps.pin.outputs.changed == 'true'
493 # commits through the API, so the author is the App bot and the gate above still passes
494 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
495 with:
496 token: ${{ steps.push_token.outputs.token }}
497 commit-message: ${{ steps.pr.outputs.title }}
498 branch: ${{ steps.pr.outputs.branch }}
499 base: dev
500 delete-branch: true
501 sign-commits: true
502 title: ${{ steps.pr.outputs.title }}
503 body-path: /tmp/body
504 labels: |
505 dependencies
506