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