name: Status check on: pull_request: branches: [main] # A newer push to the same PR makes the in-flight run obsolete. An emulator matrix is # expensive, so cancel rather than let runs pile up. concurrency: group: status-check-${{ github.ref }} cancel-in-progress: true # Third-party actions are pinned to a commit rather than a tag. A tag is mutable: the # owner can repoint v4 at new code, so a tag reference is an open invitation to run # whatever that repository contains tomorrow. The trailing comment records which # release each hash corresponds to, since a bare hash is unreadable. # Nothing here writes: these jobs read the code, build it and attach reports. Declared # explicitly rather than inherited from the repository default, for the same reason the # action SHAs above are pinned -- the token's reach should be readable here, and a default # that widens later should not silently widen these jobs with it. build.yml's release job # makes the opposite declaration for the same reason. permissions: contents: read env: GRADLE_CACHE_PATHS: | ~/.gradle/caches ~/.gradle/wrapper jobs: # --------------------------------------------------------------------------- # Validates the committed FFmpeg archive. It does not build anything: the whole # point of checking the binary in is that a red run means broken code rather than # a cross-compile that hiccuped. # # Its own job so a bad archive reports once, clearly, instead of surfacing as five # confusing emulator failures. It takes seconds, so gating the matrix on it costs # almost nothing. # --------------------------------------------------------------------------- ffmpeg: name: FFmpeg binary runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Verify the committed archive run: | AAR=bin/ffmpeg-kit-next-8.1.1.aar test -f "$AAR" || { echo "::error::$AAR is missing"; exit 1; } # A truncated file, or a Git LFS pointer checked out without LFS, would pass # a file-exists check and then surface much later as a confusing linker # error. Assert the archive actually carries native libraries for both ABIs. for abi in arm64-v8a x86_64; do n=$(unzip -l "$AAR" | grep -c "jni/$abi/.*\.so$" || true) echo " $abi: $n shared libraries" test "$n" -gt 0 || { echo "::error::AAR has no $abi libraries"; exit 1; } done # 16 KB alignment is a Play requirement and is easy to lose in a rebuild, # so it is checked here rather than discovered at submission. unzip -q -o "$AAR" 'jni/*' -d /tmp/aarcheck bad=0 for f in /tmp/aarcheck/jni/*/*.so; do align=$(readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u) if [ "$align" != "0x4000" ]; then echo "::error::$(basename "$f") is $align, not 16 KB aligned"; bad=1 fi done test "$bad" -eq 0 || exit 1 echo " all libraries are 16 KB aligned" # Record what shipped, so a failing run elsewhere can be tied to a version. echo " sha256: $(sha256sum "$AAR" | cut -d' ' -f1)" # --------------------------------------------------------------------------- # JVM tests: the routing matrix, the FFmpeg argument builder, the concat planner # and the retry rule. No device needed, so this is the fastest signal on a PR. # --------------------------------------------------------------------------- unit: name: Unit tests runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: '25' # Matches the daemon JVM pinned in gradle/gradle-daemon-jvm.properties # Gradle is invoked through the committed wrapper rather than a setup action. # The wrapper verifies its own distribution against distributionSha256Sum, and # caching is a handful of lines, so the action earned little here. - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.GRADLE_CACHE_PATHS }} key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- - name: Unit tests run: ./gradlew :app:testDebugUnitTest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: unit-test-report path: app/build/reports/tests/ # Reported, not gated. A coverage floor is only meaningful against a measured # baseline, and this is the thing that measures it -- currently 31% of lines. Once # that number has settled, a jacocoTestCoverageVerification task can hold it. - name: Coverage report run: ./gradlew :app:jacocoTestReport - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: coverage-report path: app/build/reports/jacoco/jacocoTestReport/ # --------------------------------------------------------------------------- # Three tools, one job, because they answer three different questions and a # developer wants all three answers at once rather than one per push. # # ktlint -- formatting. Owns it outright; detekt's formatting ruleset is off, # so the two can never disagree about the same line. # detekt -- static analysis. Its config lives in config/detekt/detekt.yml and # overrides only the rules this codebase legitimately breaks. # lint -- the Android-specific things neither of the others can see: opt-in # markers, API-level misuse, manifest and resource problems. # # --continue is what makes it one round trip: a ktlint failure still lets detekt # and lint report, so a red run hands over the whole list rather than the first # item on it. # # No emulator and no FFmpeg archive needed, so this is the cheapest gate here and # deliberately does not depend on the ffmpeg job. # --------------------------------------------------------------------------- static-analysis: name: Static analysis runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: '25' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.GRADLE_CACHE_PATHS }} key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- # Shell is the other language in this repo -- four scripts, one of them the CI # entry point itself -- and nothing was checking it. `git ls-files` rather than a # fixed list, so a script added later is covered without editing this workflow. # # Full severity, `info` included. The findings it raises today are answered with # targeted `disable` directives carrying their reason, the same way # config/detekt/detekt.yml carries only the rules this codebase legitimately # breaks. A blanket --severity=warning would have hidden them and the next real # one alike. # # PINNED BY DIGEST, for the reason CLAUDE.md already gives for pinning ktlint, # detekt and JaCoCo: a new rule in a linter makes files nobody touched stop # passing, so CI goes red on a PR whose diff cannot explain it. That is not # hypothetical here. The first cut of this step used the runner's ambient # shellcheck, which is 0.9.0, and 0.9.0 reports a trap handler as seven # unreachable commands (SC2317) where 0.11.0 reports it once on the declaration # (SC2329) -- same script, same directive, different answer, and a red build on # the PR that introduced the step. The version is printed so a finding that # appears out of nowhere can be tied to a bump of this line. - name: shellcheck env: SHELLCHECK: koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d run: | docker run --rm "$SHELLCHECK" --version git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK" # actionlint closes the half shellcheck cannot see. The step above reads .sh files; # a good deal of this repo's bash lives in inline `run:` blocks instead -- the release # verification here, the emulator setup and teardown in this file and in # api37-debug.yml. actionlint parses each workflow and runs shellcheck over every # `run:`, on top of its own checks for expression syntax, `needs:` references, matrix # keys and action input names. # # Pinned by digest for the same reason shellcheck is, and with a second reason of its # own: actionlint's documented install is `bash <(curl -s .../download-actionlint.bash)` # off a moving branch, which would sit badly in a repo that pins every action by SHA. - name: actionlint env: ACTIONLINT: rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7 run: | docker run --rm "$ACTIONLINT" -version docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT" -color # `!cancelled()` rather than a plain sequence: a shellcheck failure above must not # cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one # round trip should produce every list, not stop at the first. - name: ktlint, detekt and Android lint if: '!cancelled()' run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace # The XML matters as much as the HTML: it is the one that can be diffed between # runs to see what a change actually moved. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: static-analysis-reports path: | app/build/reports/ktlint/ app/build/reports/detekt/ app/build/reports/lint-results-debug.html app/build/reports/lint-results-debug.xml # --------------------------------------------------------------------------- # One runner per API level, across the whole supported range. # # The range is the point: minSdk is 33, and the foreground service type differs # across it -- none below 34, dataSync at 34, mediaProcessing from 35. Testing a # single level would leave two thirds of that branch unexercised. # # It reaches targetSdk 37, but the API 37 row is not like the other four and the # comment on it says how. Two tests are excluded there and run in their own # advisory job below. See docs/api-37-emulator-crash.md. # # FFmpeg is not built here. The AAR is committed under bin/, so a red run means the # code is broken rather than that a cross-compile hiccuped. # --------------------------------------------------------------------------- e2e: name: E2E API ${{ matrix.label }} runs-on: ubuntu-latest needs: ffmpeg timeout-minutes: 60 strategy: # Report every API level rather than stopping at the first red one. Knowing # whether a failure is universal or specific to one level is most of the # diagnosis. fail-fast: false matrix: include: - label: "33" api-level: "33" - label: "34" api-level: "34" - label: "35" api-level: "35" - label: "36" api-level: "36" # API 37, and it is NOT the same device as the four rows above it. # # CAVEAT, read this before trusting a green here: this leg runs with # SystemUI disabled and the framework restarted under it. No other leg # and no Pixel run uses that configuration. It is defensible only because # nothing THIS LEG RUNS touches system UI -- Media3, FFmpeg and # WorkManager tests -- and because the alternative is no CI coverage of # the level this app targets. **Anything that ever does depend on system # UI must not trust this row.** E2E_DISABLE_SYSTEM_UI is what does it; # .github/scripts/e2e-run.sh explains the mechanism and why every step of # it is verified rather than assumed. # # "this leg" and not "this suite", since 2026-08-24, and the difference is # now load-bearing: SafPickerRoundTripTest DOES touch system UI. It drives # DocumentsUI and rotates the display, and both reach the gralloc mapper # this image aborts in -- disabling SystemUI removes the IDLE trigger, not # those. Measured per method on android-37.0: the ROTATION test takes the # framework down (INSTRUMENTATION_ABORTED) and carries # @FailsOnEmulatorApi37, so notAnnotation below keeps it off this row; the # PICKER test passes and runs here like anything else. A rotation rebuilds # every surface at once, and starting another app's activity does not. # # So this row does now run one test that depends on system UI, and the # caveat above still applies to it: a green here is not evidence the picker # works on a device with SystemUI running -- the Pixel release check is. # docs/api-37-emulator-crash.md has the per-method measurements, and the # correction that produced them. # # api-level must be a POINT release. A bare 37 is not an SDK package and # fails during setup, which cost a run to discover. `37.0` is the choice # here rather than the only option: `37.1` and `37.2-beta*` exist and # abort the same way, and api37-debug.yml's inputs document both, with # the wrinkle that above 37.0 they ship only as google_apis_ps16k. # docs/api-37-emulator-crash.md measures 37.0 rev 6 and 37.1 rev 8 side # by side, so pinning 37.0 is a decision, not a constraint. # # notAnnotation removes the three tests that do not pass on this image; they # run in the advisory job below, off the same marker so they cannot end up # in both or neither. docs/api-37-emulator-crash.md has the measurements. - label: "37" api-level: "37.0" disable-system-ui: "1" gradle-args: "-Pandroid.testInstrumentationRunnerArguments.notAnnotation=org.libremediaconverter.FailsOnEmulatorApi37" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: '25' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.GRADLE_CACHE_PATHS }} key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle-${{ runner.os }}- # Without this the emulator falls back to software rendering and takes minutes # longer to boot, when it boots at all. - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Instrumented tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 # Both of these are empty on every row but 37, and both are read with a # `:-` default in e2e-run.sh, so the four legs below 37 run the identical # gradle command they always have. env: E2E_DISABLE_SYSTEM_UI: ${{ matrix.disable-system-ui }} E2E_EXTRA_GRADLE_ARGS: ${{ matrix.gradle-args }} with: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 profile: pixel_6 # swiftshader_indirect is correct here only because runners have no GPU to # pass through. On a workstation the same setting routes through # SwiftShader's JIT, which is a known crash source. emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true # The default userdata partition is not big enough for this APK once the # FFmpeg libraries are in it. One level failed outright with "Requested # internal only, but not enough space", and the margin was thin everywhere # else, so give them all room. disk-size: 8G # Pinned because the emulator's own default is not uniform: it raises an # undersized guest to a floor that varies by API level -- 2048M at 33, 2560M # at 34 through 36 -- and skips levels it does not recognise entirely. 2560M # is the highest of those floors, so no level gets less memory than it # already had, and none of them depend on that heuristic any more. ram-size: 2560M # Build only the ABI the emulator can execute. FFmpeg's native libraries # dominate the APK, so shipping arm64 to an x86_64 emulator doubles the # install for code that can never run: 114 MB against 80 MB. # # Diagnostics live in .github/scripts/e2e-run.sh, not here. This action splits # `script` on newlines and runs each line as its own `sh -c`, so a handler written # inline has to fit on ONE line -- which is how the previous version ended up as an # unreadable chain of semicolons. One line invokes the script; the script can use # functions, and captures a hang as well as a failure. See its header. script: bash .github/scripts/e2e-run.sh ${{ matrix.label }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: e2e-report-api${{ matrix.label }} path: | app/build/reports/androidTests/ app/build/outputs/androidTest-results/ if-no-files-found: warn # The streamed logcat and the failure dump. Uploaded always, because a leg that goes red # once and green on re-run is exactly the one worth reading afterwards, and by then the # emulator is long gone. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: e2e-diagnostics-api${{ matrix.label }} path: | ${{ runner.temp }}/logcat-api${{ matrix.label }}.txt ${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt ${{ runner.temp }}/gradle-api${{ matrix.label }}.txt if-no-files-found: warn # Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet # instead of warning on every green leg. - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: e2e-wedge-api${{ matrix.label }} path: ${{ runner.temp }}/wedge-diagnostics-api${{ matrix.label }}.txt if-no-files-found: ignore # --------------------------------------------------------------------------- # The three API 37 tests the gating row above excludes, run on their own so they # stay visible instead of disappearing behind a notAnnotation. # # continue-on-error: it reports, it never blocks. That is the whole reason it is # a separate job rather than a sixth matrix row: a row would share the gating # job's `E2E API