diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ca79ca5b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cd917fd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,143 @@ +# GitHub Actions: lint + unit tests + security scans. +# Does not build or publish artifacts. Image scanning is done via +# trivy fs on the project tree (covers deps, secrets, and Dockerfile +# misconfig) so CI does not need a docker-in-docker setup. + +name: CI + +on: + pull_request: + push: + branches: [main] + release: + types: [published] + +env: + UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build -- CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" + +jobs: + lint: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v4 + - run: uv sync --frozen --all-extras + - run: uv run ruff check app/ tests/ + - run: uv run ruff format --check app/ tests/ + - run: bash -n run.sh + + test: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v4 + - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg + - run: uv sync --frozen --all-extras + - run: uv run pytest tests/ -q + + js-syntax: + runs-on: ubuntu-latest + container: + image: node:20-alpine + steps: + - uses: actions/checkout@v4 + - run: for f in static/js/*.js; do node --check "$f"; done + + sast-bandit: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v4 + - run: uv tool install bandit + - run: uv tool run bandit -r app/ -ll # fail on medium+ severity + + deps-audit: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v4 + - run: uv tool install pip-audit + - run: uv pip compile pyproject.toml -o /tmp/requirements.txt + # Ignored CVEs (review when upgrading torch or demucs): + # + # torch 2.6.0 -- pinned to <2.7 because torchaudio 2.7+ removed its + # built-in audio writer and now requires torchcodec, which has ABI + # issues that break demucs 4.0.1's torchaudio.save() path. All torch + # CVEs below are in ops that StemDeck does not invoke; risk on a + # local-only, single-user app is negligible. Re-evaluate once demucs + # supports torch 2.7+ without torchcodec. + # + # joblib PYSEC-2024-277 -- no fix version available as of 2026-05-21 + # (1.5.3 is latest). joblib is a transitive dep via demucs/librosa; + # StemDeck does not directly invoke joblib serialization. Drop once + # a patched release is available. + - run: | + uv tool run pip-audit -r /tmp/requirements.txt --strict \ + --ignore-vuln CVE-2025-2953 \ + --ignore-vuln CVE-2025-3730 \ + --ignore-vuln PYSEC-2025-189 \ + --ignore-vuln PYSEC-2025-190 \ + --ignore-vuln PYSEC-2025-192 \ + --ignore-vuln PYSEC-2025-193 \ + --ignore-vuln PYSEC-2025-194 \ + --ignore-vuln PYSEC-2025-195 \ + --ignore-vuln PYSEC-2025-196 \ + --ignore-vuln PYSEC-2025-197 \ + --ignore-vuln PYSEC-2025-198 \ + --ignore-vuln PYSEC-2025-199 \ + --ignore-vuln PYSEC-2025-200 \ + --ignore-vuln PYSEC-2025-201 \ + --ignore-vuln PYSEC-2025-202 \ + --ignore-vuln PYSEC-2025-203 \ + --ignore-vuln PYSEC-2025-204 \ + --ignore-vuln PYSEC-2025-205 \ + --ignore-vuln PYSEC-2025-206 \ + --ignore-vuln PYSEC-2025-207 \ + --ignore-vuln PYSEC-2025-208 \ + --ignore-vuln PYSEC-2025-209 \ + --ignore-vuln PYSEC-2025-210 \ + --ignore-vuln PYSEC-2026-139 \ + --ignore-vuln PYSEC-2024-277 \ + --ignore-vuln CVE-2025-2148 \ + --ignore-vuln CVE-2025-2149 \ + --ignore-vuln CVE-2025-2998 \ + --ignore-vuln CVE-2025-2999 \ + --ignore-vuln CVE-2025-3000 \ + --ignore-vuln CVE-2025-3001 + + trivy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Scans the source tree for: known CVEs in deps, leaked secrets, + # and Dockerfile / compose misconfigurations. Skips .venv (it can + # be left over from earlier steps in the shared workspace; trivy + # would scan its bundled extractor files and flag false-positive + # secrets that ship inside third-party packages like yt-dlp). + - name: trivy fs + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + exit-code: '1' + ignore-unfixed: true + trivyignores: .trivyignore + skip-dirs: .venv,jobs + # Dedicated Dockerfile + compose static analysis (Trivy's IaC linter). + - name: trivy config + uses: aquasecurity/trivy-action@master + with: + scan-type: config + scan-ref: build/ + severity: HIGH,CRITICAL + exit-code: '1' diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 00000000..3f4ef556 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,132 @@ +name: macOS Release + +on: + push: + tags: + - 'v*' + release: + types: [published] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-upload: + # Runner must be darwin/arm64 with Rosetta 2, Xcode CLT, Homebrew, rustup, and zstd. + runs-on: [self-hosted, osx, arm64] + timeout-minutes: 120 + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - name: clean workspace + run: rm -rf .build dist + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: write version files + run: | + if [ -z "${GITHUB_REF_NAME:-}" ]; then + echo "GITHUB_REF_NAME is not set" >&2 + exit 1 + fi + VERSION="${GITHUB_REF_NAME#v}" + printf '{ "version": "%s" }\n' "$VERSION" > static/version.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml + sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Wrote version $VERSION to all version files" + + - name: build macOS arm64 + env: + SSL_CERT_FILE: /etc/ssl/cert.pem + REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + run: | + command -v zstd + command -v uv >/dev/null 2>&1 || brew install uv + uv python install cpython-3.12-macos-aarch64-none + ARM64_PYTHON="$(uv python find cpython-3.12-macos-aarch64-none)" + scripts/macos/make-iconset.sh + ARCH=arm64 VERSION="$VERSION" PYTHON_BIN="$ARM64_PYTHON" scripts/macos/make-runtime-pack.sh + ARCH=arm64 VERSION="$VERSION" scripts/macos/make-app.sh + ARCH=arm64 VERSION="$VERSION" scripts/macos/make-dmg.sh + + - name: build macOS x64 + env: + SSL_CERT_FILE: /etc/ssl/cert.pem + REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + run: | + command -v zstd + if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + echo "ERROR: Rosetta 2 is required to build the x64 runtime on this agent." >&2 + exit 1 + fi + rustup default stable + rustup target add x86_64-apple-darwin + command -v uv >/dev/null 2>&1 || brew install uv + uv python install cpython-3.12-macos-x86_64-none + X64_PYTHON="$(uv python find cpython-3.12-macos-x86_64-none)" + ARCH=x64 VERSION="$VERSION" PYTHON_BIN="$X64_PYTHON" scripts/macos/make-runtime-pack.sh + ARCH=x64 VERSION="$VERSION" scripts/macos/make-app.sh + ARCH=x64 VERSION="$VERSION" scripts/macos/make-dmg.sh + + - name: inspect artifacts + run: | + test -f .build/macos-dist/StemDeck-macOS-arm64.dmg + test -f .build/StemDeck-runtime-macOS-arm64.tar.zst + test -f .build/macos-dist/SHA256SUMS-macOS-arm64.txt + test -f .build/macos-dist/StemDeck-macOS-x64.dmg + test -f .build/StemDeck-runtime-macOS-x64.tar.zst + test -f .build/macos-dist/SHA256SUMS-macOS-x64.txt + cat .build/macos-dist/SHA256SUMS-macOS-arm64.txt + cat .build/macos-dist/SHA256SUMS-macOS-x64.txt + du -sh .build/macos-dist/StemDeck-macOS-arm64.dmg .build/StemDeck-runtime-macOS-arm64.tar.zst + du -sh .build/macos-dist/StemDeck-macOS-x64.dmg .build/StemDeck-runtime-macOS-x64.tar.zst + if find desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app \ + \( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) | + grep -q .; then + echo "arm64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2 + exit 1 + fi + if find desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app \ + \( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) | + grep -q .; then + echo "x64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2 + exit 1 + fi + for arch in arm64 x64; do + mountpoint="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)" + hdiutil attach ".build/macos-dist/StemDeck-macOS-$arch.dmg" -readonly -nobrowse -mountpoint "$mountpoint" + trap 'hdiutil detach "$mountpoint" >/dev/null 2>&1 || true; rmdir "$mountpoint" >/dev/null 2>&1 || true' EXIT + test -d "$mountpoint/StemDeck.app" + test -L "$mountpoint/Applications" + test -f "$mountpoint/README-macOS.txt" + test -f "$mountpoint/THIRD_PARTY_NOTICES.txt" + hdiutil detach "$mountpoint" + rmdir "$mountpoint" + trap - EXIT + done + + - name: upload artifacts + if: github.event_name == 'release' + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: | + .build/macos-dist/StemDeck-macOS-arm64.dmg + .build/StemDeck-runtime-macOS-arm64.tar.zst + .build/macos-dist/SHA256SUMS-macOS-arm64.txt + .build/macos-dist/StemDeck-macOS-x64.dmg + .build/StemDeck-runtime-macOS-x64.tar.zst + .build/macos-dist/SHA256SUMS-macOS-x64.txt + append_body: true + body: | + ### Artifact build + + - macOS arm64 and x64 DMGs and runtime packs were built and inspected on a macOS GitHub Actions runner before upload. diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 00000000..48196c2c --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,107 @@ +name: Windows Release + +on: + push: + tags: + - 'v*' + release: + types: [published] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-upload: + # Runner must be windows/x64 with PowerShell, Docker, and rustup. + runs-on: [self-hosted, windows, x64] + timeout-minutes: 90 + permissions: + contents: write + defaults: + run: + shell: pwsh + steps: + - name: clean workspace + run: | + Remove-Item -Recurse -Force .build, dist -ErrorAction SilentlyContinue + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: write version files + run: | + $tag = $env:GITHUB_REF_NAME + if (-not $tag) { throw "GITHUB_REF_NAME is not set" } + $version = $tag -replace '^v', '' + $json = "{`"version`": `"$version`"}" + Set-Content -Path "static/version.json" -Value $json -Encoding UTF8 + (Get-Content "desktop/src-tauri/Cargo.toml") -replace '^version = ".*"', "version = `"$version`"" | + Set-Content "desktop/src-tauri/Cargo.toml" + (Get-Content "desktop/src-tauri/tauri.conf.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | + Set-Content "desktop/src-tauri/tauri.conf.json" + (Get-Content "pyproject.toml") -replace '^version = ".*"', "version = `"$version`"" | + Set-Content "pyproject.toml" + (Get-Content "desktop/package.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | + Set-Content "desktop/package.json" + Write-Host "Wrote version $version to all version files" + + - name: build Windows NVIDIA + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName StemDeck-Windows-x64.NVIDIA ` + -PackageVersion "$env:GITHUB_REF_NAME" ` + -StripVenv + + - name: build Windows CPU + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName StemDeck-Windows-x64 ` + -PackageVersion "$env:GITHUB_REF_NAME" ` + -CpuOnly ` + -StripVenv + + - name: scan artifacts + run: | + Write-Host "Preparing ClamAV scan for Windows release artifacts..." + Write-Host "Artifacts staged in: $PWD\dist" + Get-ChildItem -Path "dist" -File | + Sort-Object Name | + Select-Object Name, + @{Name="SizeMB"; Expression={ [math]::Round($_.Length / 1MB, 2) }} | + Format-Table -AutoSize + + Write-Host "SHA256 checksums:" + Get-ChildItem -Path "dist" -Filter "*.zip" -File | + Sort-Object Name | + ForEach-Object { + $hash = Get-FileHash -Algorithm SHA256 $_.FullName + Write-Host " $($hash.Hash) $($_.Name)" + } + + Write-Host "Pulling latest ClamAV scanner image..." + docker pull clamav/clamav:latest + + Write-Host "Running ClamAV scan over dist/..." + docker run --rm -v "${PWD}/dist:/scan:ro" clamav/clamav:latest ` + clamscan --recursive --infected --bell /scan + if ($LASTEXITCODE -ne 0) { + throw "ClamAV scan failed or reported infected files. Exit code: $LASTEXITCODE" + } + Write-Host "ClamAV scan completed successfully. No infected files reported." + + - name: upload artifacts + if: github.event_name == 'release' + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: | + dist/StemDeck-Windows-x64.NVIDIA.zip + dist/StemDeck-Windows-x64.NVIDIA.zip.sha256 + dist/StemDeck-Windows-x64.zip + dist/StemDeck-Windows-x64.zip.sha256 + append_body: true + body: | + ### Artifact scan + + - Windows portable packages were scanned with ClamAV in CI before upload. diff --git a/uv.lock b/uv.lock index 62c61f3a..fcbaabd9 100644 --- a/uv.lock +++ b/uv.lock @@ -1346,11 +1346,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -1752,15 +1752,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -2264,9 +2264,9 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2026.3.17" +version = "2026.6.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" }, ]