/
/
1# This workflow will install Python dependencies, run tests and lint
2# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3
4name: Test
5
6on:
7 push:
8 branches:
9 - stable
10 - dev
11 pull_request:
12 branches:
13 - stable
14 - dev
15 merge_group:
16 workflow_dispatch:
17 workflow_call:
18 inputs:
19 ref:
20 description: "Git ref to checkout"
21 required: false
22 type: string
23
24permissions:
25 contents: read
26
27# Keep urllib3-future from hijacking the urllib3 namespace (see pyproject.toml).
28env:
29 URLLIB3_NO_OVERRIDE: "1"
30
31concurrency:
32 group: test-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.ref || github.ref }}
33 # Not merge_group: a cancelled run reports no check, hanging the queue entry until timeout.
34 cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
35
36jobs:
37 changes:
38 runs-on: ubuntu-latest
39 permissions:
40 contents: read
41 pull-requests: read
42 outputs:
43 mode: ${{ steps.scope.outputs.mode }}
44 test_paths: ${{ steps.scope.outputs.test_paths }}
45 cov_paths: ${{ steps.scope.outputs.cov_paths }}
46 steps:
47 - name: Check out code from GitHub
48 uses: actions/checkout@v7
49 with:
50 ref: ${{ inputs.ref || github.ref }}
51 # On PRs, run only the tests affected by the changed files: a PR touching a
52 # single provider runs just that provider's tests. A change to shared code
53 # (incl. translations under music_assistant/), dependencies or the CI itself
54 # runs the full suite, as do pushes, merge groups, manual runs and
55 # reusable-workflow calls.
56 # PRs that only touch docs or other non-code files run nothing.
57 - name: Determine test scope
58 id: scope
59 env:
60 GH_TOKEN: ${{ github.token }}
61 run: |
62 if [ "${{ github.event_name }}" != "pull_request" ]; then
63 {
64 echo "mode=full"
65 echo "test_paths="
66 echo "cov_paths="
67 } >> "$GITHUB_OUTPUT"
68 exit 0
69 fi
70 # Emit both the new and (for renames) the previous path so a file moved
71 # between providers runs the source provider's tests too.
72 gh api --paginate \
73 "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
74 --jq '.[] | .filename, (.previous_filename // empty)' | python3 scripts/ci_test_scope.py
75
76 provider-scope:
77 # Advisory only (never fails the build): emit warning annotations when a PR that touches a
78 # provider also changes unrelated shared code, suggesting those changes move to their own PR.
79 if: ${{ github.event_name == 'pull_request' }}
80 runs-on: ubuntu-latest
81 permissions:
82 contents: read
83 pull-requests: read
84 steps:
85 - name: Check out code from GitHub
86 uses: actions/checkout@v7
87 with:
88 ref: ${{ inputs.ref || github.ref }}
89 - name: Warn on out-of-scope provider changes
90 env:
91 GH_TOKEN: ${{ github.token }}
92 run: |
93 gh api --paginate \
94 "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
95 --jq '.[] | .filename, (.previous_filename // empty)' | python3 scripts/check_provider_scope.py
96
97 lint:
98 runs-on: ubuntu-latest
99
100 steps:
101 - name: Check out code from GitHub
102 uses: actions/checkout@v7
103 with:
104 ref: ${{ inputs.ref || github.ref }}
105 - name: Set up Python
106 uses: actions/[email protected]
107 with:
108 python-version-file: ".python-version"
109 check-latest: true
110 - name: Install uv
111 run: pip install uv
112 - name: Cache uv packages
113 uses: actions/cache@v6
114 with:
115 path: ~/.cache/uv
116 key: uv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('requirements_all.txt', 'pyproject.toml') }}
117 - name: Install dependencies
118 # --index-strategy: allow PyPI packages when also using the PyTorch extra index
119 # https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes
120 run: uv pip install --system --index-strategy unsafe-best-match . .[test] -r requirements_all.txt
121 - name: Lint/test with pre-commit
122 run: SKIP=no-commit-to-branch pre-commit run --all-files
123
124 test:
125 needs: changes
126 if: ${{ needs.changes.outputs.mode != 'skip' }}
127 runs-on: ubuntu-latest
128 # Same base as the production image (Dockerfile.base), so apt ffmpeg is the same
129 # 7.1.x line we ship; ubuntu-latest's apt only has 6.1. Keep the Python tag in
130 # sync with .python-version.
131 container: python:3.14-slim-trixie
132 # Backstop: a hung test process should fail in minutes, not sit at the 6h job cap.
133 timeout-minutes: 30
134
135 steps:
136 # Cache apt .deb files to speed up ffmpeg installation (in home to avoid permission issues).
137 - name: Cache apt packages
138 uses: actions/cache@v6
139 with:
140 path: ~/.cache/apt-packages
141 key: apt-ffmpeg-${{ runner.os }}-trixie
142 # git must be present before checkout, or it degrades to a REST tarball download;
143 # build-essential + libssl-dev are needed to build aiolibdatachannel from its git
144 # source; curl + gpg are needed by the codecov action; libportaudio2 is needed by
145 # the local audio tests on the sounddevice backend, which skip rather than fail
146 # without it
147 - name: Install ffmpeg and build tools
148 run: |
149 # Restore cached .deb files to apt cache (if any)
150 cp ~/.cache/apt-packages/*.deb /var/cache/apt/archives/ 2>/dev/null || true
151 apt-get update && apt-get install -y ffmpeg libportaudio2 git build-essential libssl-dev curl gpg
152 # Save .deb files for next run
153 mkdir -p ~/.cache/apt-packages && cp /var/cache/apt/archives/*.deb ~/.cache/apt-packages/ 2>/dev/null || true
154 - name: Check out code from GitHub
155 uses: actions/checkout@v7
156 with:
157 ref: ${{ inputs.ref || github.ref }}
158 - name: Install uv
159 run: pip install uv
160 - name: Cache uv packages
161 uses: actions/cache@v6
162 with:
163 path: ~/.cache/uv
164 key: uv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('requirements_all.txt', 'pyproject.toml') }}
165 - name: Install dependencies
166 # --index-strategy: allow PyPI packages when also using the PyTorch extra index
167 # https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes
168 run: uv pip install --system --index-strategy unsafe-best-match . .[test] -r requirements_all.txt
169 - name: Pytest
170 # mode=partial runs only the changed providers' tests with coverage scoped to
171 # those provider packages; mode=full runs everything with full coverage.
172 # --dist loadfile keeps each test file on one xdist worker: snapshot updates
173 # never race on an .ambr file and module-scoped fixtures are reused.
174 # --max-worker-restart=0: with loadfile, replacing a worker that died on a native
175 # crash leaves the controller blocked on its event queue forever, so the job only
176 # ends at its 30m cap with no summary. Shutting down instead fails in seconds and
177 # names the crashing test; a crashed worker invalidates the run either way.
178 env:
179 MODE: ${{ needs.changes.outputs.mode }}
180 TEST_PATHS: ${{ needs.changes.outputs.test_paths }}
181 COV_PATHS: ${{ needs.changes.outputs.cov_paths }}
182 # aiolibdatachannel's _native.so doesn't link libstdc++ itself; the production
183 # image resolves it via its jemalloc LD_PRELOAD, here we preload it directly
184 LD_PRELOAD: libstdc++.so.6
185 run: |
186 if [ "$MODE" = "partial" ]; then
187 targets="$TEST_PATHS"
188 cov=""
189 for src in $COV_PATHS; do cov="$cov --cov=$src"; done
190 else
191 targets="tests/"
192 cov="--cov=music_assistant"
193 fi
194 # the container runs as root, which bypasses file permission bits and breaks
195 # tests exercising permission-denied paths; run pytest as an unprivileged user
196 useradd -m tester
197 chown -R tester:tester .
198 # -p numpy: pre-import numpy before pytest-cov starts coverage. Scoping
199 # --cov to a submodule (partial mode) makes coverage import the parent
200 # music_assistant package inside its sys_modules_saved() block, which then
201 # evicts numpy from sys.modules. Re-importing numpy reinitialises its C
202 # extension and creates a second _NoValue sentinel, breaking ndarray
203 # reductions (sum/mean) on py3.14. Loading numpy as an early plugin keeps
204 # it in the saved snapshot so it is never evicted.
205 setpriv --reuid=tester --regid=tester --init-groups \
206 env HOME=/home/tester \
207 pytest -p numpy -n auto --dist loadfile --max-worker-restart=0 --durations 10 $cov --cov-report=term-missing --cov-report=xml $targets
208 - name: Upload coverage to Codecov
209 if: ${{ !github.event.pull_request.head.repo.fork }}
210 uses: codecov/codecov-action@v7
211 with:
212 files: ./coverage.xml
213 flags: ${{ needs.changes.outputs.mode }}
214 fail_ci_if_error: false
215 env:
216 CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
217