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