/
/
/
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
12env:
13 EXPECTED_APP_BOT_LOGIN: musicassistant-bot[bot]
14 EXPECTED_APP_BOT_LOGIN_ENCODED: musicassistant-bot%5Bbot%5D
15 EXPECTED_APP_BOT_ID: "304008617"
16
17# CRITICAL SECURITY: This workflow uses pull_request_target which runs in the context
18# of the base repository and has access to secrets. Multiple security checks ensure
19# only trusted automation PRs are auto-merged.
20
21jobs:
22 auto-merge:
23 name: Auto-approve and merge
24 runs-on: ubuntu-latest
25 # Only run if branch name matches the expected pattern
26 if: |
27 startsWith(github.event.pull_request.head.ref, 'auto-update-frontend-') ||
28 startsWith(github.event.pull_request.head.ref, 'auto-update-models-')
29
30 permissions:
31 contents: write
32 pull-requests: write
33
34 steps:
35 # Security check 1: Verify PR is from the exact expected GitHub App bot
36 - name: Verify PR is from trusted source
37 id: verify_pr_author
38 run: |
39 # GitHub App bots are not collaborators. Trust only the known App account
40 # when the event also proves it is a same-repository Bot PR.
41 if [ "$PR_AUTHOR" != "$EXPECTED_APP_BOT_LOGIN" ] || \
42 [ "$PR_AUTHOR_TYPE" != "Bot" ] || \
43 [ "$PR_AUTHOR_ID" != "$EXPECTED_APP_BOT_ID" ] || \
44 [ "$HEAD_REPOSITORY" != "$BASE_REPOSITORY" ]; then
45 echo "â GitHub App PR identity does not match the trusted source"
46 exit 1
47 fi
48
49 if ! BOT_PROFILE=$(gh api "/users/$EXPECTED_APP_BOT_LOGIN_ENCODED" 2>/dev/null); then
50 echo "â Could not verify the GitHub App bot profile"
51 exit 1
52 fi
53 if [ "$(jq -r '.login' <<< "$BOT_PROFILE")" != "$EXPECTED_APP_BOT_LOGIN" ] || \
54 [ "$(jq -r '.type' <<< "$BOT_PROFILE")" != "Bot" ] || \
55 [ "$(jq -r '.id' <<< "$BOT_PROFILE")" != "$EXPECTED_APP_BOT_ID" ]; then
56 echo "â GitHub App bot profile does not match the trusted identity"
57 exit 1
58 fi
59 echo "â
PR is from the trusted GitHub App bot"
60 env:
61 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62 BASE_REPOSITORY: ${{ github.repository }}
63 HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
64 PR_AUTHOR: ${{ github.event.pull_request.user.login }}
65 PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
66 PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
67
68 # Security check 2: Verify PR labels and source branch
69 - name: Verify PR labels and source
70 run: |
71 LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
72
73 if [[ "$LABELS" != *"dependencies"* ]]; then
74 echo "â PR does not have 'dependencies' label"
75 exit 1
76 fi
77
78 if [[ "$BRANCH" != auto-update-frontend-* && "$BRANCH" != auto-update-models-* ]]; then
79 echo "â Branch name does not match expected pattern: $BRANCH"
80 exit 1
81 fi
82
83 echo "â
PR has 'dependencies' label and valid branch name"
84
85 env:
86 BRANCH: ${{ github.event.pull_request.head.ref }}
87
88 # NOTE: The PR is intentionally never checked out. All validation below
89 # uses the GitHub API, so no untrusted code ever reaches this privileged
90 # workflow's filesystem.
91
92 - name: Get PR details
93 id: pr
94 run: |
95 echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
96
97 # Security check 4: Verify every commit is authored by the expected GitHub App bot
98 - name: Verify commit authors
99 run: |
100 # A dependency bump PR only ever needs a handful of commits
101 COMMIT_COUNT="${{ github.event.pull_request.commits }}"
102 if [ "$COMMIT_COUNT" -gt 20 ]; then
103 echo "â PR has $COMMIT_COUNT commits, too many for a dependency update"
104 exit 1
105 fi
106
107 COMMITS=$(gh api "/repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/commits" --paginate --jq '.[]' | jq -s '.')
108
109 # Guard against API truncation: we must have seen every commit
110 FETCHED_COUNT=$(echo "$COMMITS" | jq 'length')
111 if [ "$FETCHED_COUNT" -ne "$COMMIT_COUNT" ]; then
112 echo "â Fetched $FETCHED_COUNT commits but the PR reports $COMMIT_COUNT"
113 exit 1
114 fi
115
116 # Commits whose author email is not linked to a GitHub account have
117 # no login to verify, so they must be rejected
118 UNATTRIBUTED=$(echo "$COMMITS" | jq '[.[] | select(.author.login == null)] | length')
119 if [ "$UNATTRIBUTED" -gt 0 ]; then
120 echo "â $UNATTRIBUTED commit(s) have no linked GitHub author"
121 exit 1
122 fi
123
124 UNTRUSTED_AUTHORS=$(echo "$COMMITS" | jq \
125 --arg login "$EXPECTED_APP_BOT_LOGIN" \
126 --argjson id "$EXPECTED_APP_BOT_ID" \
127 '[.[] | select(
128 .author.login != $login or
129 .author.type != "Bot" or
130 .author.id != $id
131 )] | length')
132 if [ "$UNTRUSTED_AUTHORS" -gt 0 ]; then
133 echo "â $UNTRUSTED_AUTHORS commit(s) are not authored by the trusted GitHub App bot"
134 exit 1
135 fi
136
137 echo "â
All commits are authored by the trusted GitHub App bot"
138
139 env:
140 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
141
142 # Security check 5: Verify only dependency files were changed
143 - name: Verify only dependency files were changed
144 run: |
145 # Only pyproject.toml and requirements_all.txt should be modified
146 CHANGED_FILES=$(gh api "/repos/${{ github.repository }}/pulls/${{ steps.pr.outputs.number }}/files" --paginate --jq '.[].filename')
147
148 if [[ -z "$CHANGED_FILES" ]]; then
149 echo "â Could not determine changed files"
150 exit 1
151 fi
152
153 echo "Changed files:"
154 echo "$CHANGED_FILES"
155
156 for file in $CHANGED_FILES; do
157 if [[ "$file" != "pyproject.toml" ]] && [[ "$file" != "requirements_all.txt" ]]; then
158 echo "â Unexpected file changed: $file"
159 echo "Only pyproject.toml and requirements_all.txt should be modified"
160 exit 1
161 fi
162 done
163
164 echo "â
Only expected dependency files were changed"
165 env:
166 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167
168 # Security check 6: Verify changes are only version bumps
169 - name: Verify changes are version bumps
170 run: |
171 DIFF=$(gh pr diff "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}")
172
173 # Every added/removed line (excluding file headers) must be a version
174 # pin of an allowed package, in either pyproject.toml or
175 # requirements_all.txt format.
176 UNEXPECTED=$(echo "$DIFF" \
177 | grep -E '^[+-]' \
178 | grep -vE '^(\+\+\+|---)' \
179 | grep -vE '^[+-][[:space:]]*"?music-assistant-(frontend|models)==[0-9][0-9a-zA-Z.]*"?,?[[:space:]]*$' \
180 || true)
181
182 if [[ -n "$UNEXPECTED" ]]; then
183 echo "â Diff contains changes that are not version bumps:"
184 echo "$UNEXPECTED"
185 exit 1
186 fi
187
188 # A pin must be added, not just removed
189 if ! echo "$DIFF" | grep -qE '^\+.*music-assistant-(frontend|models)=='; then
190 echo "â No added version pin found"
191 exit 1
192 fi
193
194 echo "â
Changes are version bumps"
195 env:
196 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
197
198 # Security check 7: Wait for package to be available on PyPI
199 - name: Wait for package availability on PyPI
200 run: |
201 # Extract the package name and version from the added lines of the diff
202 DIFF=$(gh pr diff "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" | grep '^+' || true)
203
204 if echo "$DIFF" | grep -q "music-assistant-frontend=="; then
205 PACKAGE="music-assistant-frontend"
206 VERSION=$(echo "$DIFF" | grep -oP 'music-assistant-frontend==\K[0-9.]+' | head -1)
207 elif echo "$DIFF" | grep -q "music-assistant-models=="; then
208 PACKAGE="music-assistant-models"
209 VERSION=$(echo "$DIFF" | grep -oP 'music-assistant-models==\K[0-9.]+' | head -1)
210 else
211 echo "â Could not determine package name and version"
212 exit 1
213 fi
214
215 echo "Waiting for $PACKAGE version $VERSION to be available on PyPI..."
216
217 # Retry for up to 20 minutes (20 attempts with 60 second intervals)
218 MAX_ATTEMPTS=20
219 SLEEP_DURATION=60
220 ATTEMPT=1
221
222 while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
223 echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Checking if $PACKAGE==$VERSION is available..."
224
225 # Try to get package info from PyPI JSON API
226 HTTP_CODE=$(curl -s -o /tmp/pypi_response.json -w "%{http_code}" "https://pypi.org/pypi/$PACKAGE/json")
227
228 if [ "$HTTP_CODE" -eq 200 ]; then
229 # Check if the specific version exists
230 if grep -q "\"$VERSION\"" /tmp/pypi_response.json; then
231 echo "â
Package $PACKAGE version $VERSION is available on PyPI"
232
233 # Additional verification: try to download the package
234 if python3 -m pip download --no-deps "$PACKAGE==$VERSION" > /dev/null 2>&1; then
235 echo "â
Package $PACKAGE==$VERSION can be installed"
236 exit 0
237 else
238 echo "â ï¸ Package found in PyPI API but pip download failed, retrying..."
239 fi
240 else
241 echo "â¹ï¸ Package $PACKAGE exists but version $VERSION not yet available"
242 fi
243 else
244 echo "â¹ï¸ HTTP $HTTP_CODE when accessing PyPI API"
245 fi
246
247 if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
248 echo "Waiting ${SLEEP_DURATION}s before retry..."
249 sleep $SLEEP_DURATION
250 fi
251
252 ATTEMPT=$((ATTEMPT + 1))
253 done
254
255 echo "â Package $PACKAGE version $VERSION did not become available within the timeout period"
256 echo "This might indicate:"
257 echo " - The package was not published to PyPI"
258 echo " - PyPI is experiencing delays"
259 echo " - The version number in the PR is incorrect"
260 exit 1
261 env:
262 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
263
264 # All security checks passed - approve the PR
265 - name: Auto-approve PR
266 run: |
267 gh pr review "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" --approve --body "â
Automated dependency update - all security checks passed"
268 env:
269 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
270
271 # Enable auto-merge with squash
272 - name: Enable auto-merge
273 run: |
274 gh pr merge "${{ steps.pr.outputs.number }}" -R "${{ github.repository }}" --auto --squash
275 env:
276 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
277
278 - name: Comment on success
279 if: success()
280 run: |
281 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."
282 env:
283 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
284