/
/
/
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
145 - name: Install ffmpeg and build tools
146 run: |
147 # Restore cached .deb files to apt cache (if any)
148 cp ~/.cache/apt-packages/*.deb /var/cache/apt/archives/ 2>/dev/null || true
149 apt-get update && apt-get install -y ffmpeg git build-essential libssl-dev curl gpg
150 # Save .deb files for next run
151 mkdir -p ~/.cache/apt-packages && cp /var/cache/apt/archives/*.deb ~/.cache/apt-packages/ 2>/dev/null || true
152 - name: Check out code from GitHub
153 uses: actions/checkout@v7
154 with:
155 ref: ${{ inputs.ref || github.ref }}
156 - name: Install uv
157 run: pip install uv
158 - name: Cache uv packages
159 uses: actions/cache@v6
160 with:
161 path: ~/.cache/uv
162 key: uv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('requirements_all.txt', 'pyproject.toml') }}
163 - name: Install dependencies
164 # --index-strategy: allow PyPI packages when also using the PyTorch extra index
165 # https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes
166 run: uv pip install --system --index-strategy unsafe-best-match . .[test] -r requirements_all.txt
167 - name: Pytest
168 # mode=partial runs only the changed providers' tests with coverage scoped to
169 # those provider packages; mode=full runs everything with full coverage.
170 # --dist loadfile keeps each test file on one xdist worker: snapshot updates
171 # never race on an .ambr file and module-scoped fixtures are reused.
172 # --max-worker-restart=0: with loadfile, replacing a worker that died on a native
173 # crash leaves the controller blocked on its event queue forever, so the job only
174 # ends at its 30m cap with no summary. Shutting down instead fails in seconds and
175 # names the crashing test; a crashed worker invalidates the run either way.
176 env:
177 MODE: ${{ needs.changes.outputs.mode }}
178 TEST_PATHS: ${{ needs.changes.outputs.test_paths }}
179 COV_PATHS: ${{ needs.changes.outputs.cov_paths }}
180 # aiolibdatachannel's _native.so doesn't link libstdc++ itself; the production
181 # image resolves it via its jemalloc LD_PRELOAD, here we preload it directly
182 LD_PRELOAD: libstdc++.so.6
183 run: |
184 if [ "$MODE" = "partial" ]; then
185 targets="$TEST_PATHS"
186 cov=""
187 for src in $COV_PATHS; do cov="$cov --cov=$src"; done
188 else
189 targets="tests/"
190 cov="--cov=music_assistant"
191 fi
192 # the container runs as root, which bypasses file permission bits and breaks
193 # tests exercising permission-denied paths; run pytest as an unprivileged user
194 useradd -m tester
195 chown -R tester:tester .
196 # -p numpy: pre-import numpy before pytest-cov starts coverage. Scoping
197 # --cov to a submodule (partial mode) makes coverage import the parent
198 # music_assistant package inside its sys_modules_saved() block, which then
199 # evicts numpy from sys.modules. Re-importing numpy reinitialises its C
200 # extension and creates a second _NoValue sentinel, breaking ndarray
201 # reductions (sum/mean) on py3.14. Loading numpy as an early plugin keeps
202 # it in the saved snapshot so it is never evicted.
203 setpriv --reuid=tester --regid=tester --init-groups \
204 env HOME=/home/tester \
205 pytest -p numpy -n auto --dist loadfile --max-worker-restart=0 --durations 10 $cov --cov-report=term-missing --cov-report=xml $targets
206 - name: Upload coverage to Codecov
207 if: ${{ !github.event.pull_request.head.repo.fork }}
208 uses: codecov/codecov-action@v7
209 with:
210 files: ./coverage.xml
211 flags: ${{ needs.changes.outputs.mode }}
212 fail_ci_if_error: false
213 env:
214 CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
215