Files
LibreMediaConverter/.github/workflows/status_check.yml
T
JMR-dev baaaa934e0 Check the shell, and stop one-off issues falling off the board
Two gaps, both found the same way -- by something going wrong quietly.

`gh issue create` does not touch the project board. The issue is created, carries its
labels, and is invisible in the Kanban, which looks exactly like a ticket nobody filed.
On 2026-08-24 eight issues filed as a scripted batch all reached the board and one filed
as a one-off minutes later did not; it surfaced only because someone went looking for it.
A batch carries the board step inside its loop. One-offs are where it slips, so
tools/github/file-issue.sh is for one-offs.

Three things it does that a two-command shell snippet would not:

  - Resolves the project, Status field and option ids BY NAME, every run. Caching them
    is the obvious optimisation and the wrong one -- a renamed or reordered column would
    then have this writing a stale id into the board with no error anywhere.

  - Reads the item back. A mutation returning 200 says the request was accepted, not that
    the board shows what was asked for; the read-back is the only step that checks the
    claim this script exists to make. It is a GraphQL query because REST cannot do it --
    the `fields` array REST returns on a project item carries Title and nothing else, so
    a REST-only check reports every item's Status as unset.

  - Exits 3, loudly, with the issue number on a line of its own, when the issue was
    created but the board step failed. That exact combination is the failure being
    prevented; it must never be the quiet path.

Shell was the other language here with nothing checking it -- four scripts, one of them
the CI entry point. shellcheck now runs in the Static analysis job over
`git ls-files '*.sh'`, so a script added later is covered without editing the workflow,
and it runs at full severity with `info` included.

That raises two findings today and both are the tool being wrong, so both are answered
with a targeted `disable` carrying its reason rather than by lowering the severity:
run-e2e.sh's `on_signal` is reported as never invoked when it is installed as the INT and
TERM trap eleven lines below it, and the `$names` inside file-issue.sh's queries are
GraphQL variables that must not expand -- expanding them would send the shell's idea of
$owner to the API instead of declaring a parameter. A blanket --severity=warning would
have hidden both, and the next real finding with them.

The gradle step gains `if: !cancelled()` so a shellcheck failure cannot cost the
ktlint/detekt/lint lists -- the same reason that step already passes --continue.

Not covered, deliberately: shellcheck here reads .sh files, not the inline `run:` blocks
in the workflows, where a good deal of this repo's bash actually lives. actionlint does
read them, and finds one pre-existing info-level issue in build.yml. Wiring it in means
pinning a container digest, because every action here is pinned by SHA and actionlint's
usual installer is a curl-pipe-bash off a moving branch. Its own ticket, not this commit.
2026-08-24 16:03:14 -05:00

449 lines
22 KiB
YAML

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 two 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. shellcheck is preinstalled on the ubuntu runner image; the version is
# printed so a finding that appears out of nowhere can be pinned to an upgrade.
- name: shellcheck
run: |
shellcheck --version
git ls-files -z '*.sh' | xargs -0 -r shellcheck
# `!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 in this suite touches system UI -- these are 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.
#
# api-level must be "37.0". A bare 37 is not an SDK package and fails
# during setup, which cost a run to discover.
#
# notAnnotation removes the two 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
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 two 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 <label>` name, and a check cannot be both required and advisory
# under one name.
#
# It is named for WHAT IT RUNS, deliberately. Both tests drive a full H.264 ->
# H.265 hardware transcode through Media3Engine -- which is exactly what
# separates them from the two Media3EngineTest cases that pass here, since those
# two never decode video. The current theory about why they fail is in the next
# paragraph, where it can be corrected without renaming a check that people have
# already learned to look for.
#
# THEORY, NOT SETTLED: the exception surfaces at `dequeueOutputBuffer` on
# `c2.goldfish.h264.decoder`, the emulator's own codec, which gets its frames out
# of a host-side colour buffer -- the same readback machinery that aborts
# surfaceflinger on this image. What is MEASURED is narrower: these two fail on
# the API 37 emulator image; pass at API 36 on this runner under the same renderer
# AND the same SystemUI-disable path; pass at API 33-36 without that path at all,
# since nothing below 37 needs it; and pass on a physical Pixel 10 Pro XL at 37. That the decoder is the culprit rather than something else
# the decode path touches is inference. docs/api-37-emulator-crash.md separates
# the two, and the images also differ on the encoder side, which is why "broken
# h264 decoder" is not written into this job's name.
#
# WHEN THIS GOES GREEN, delete the annotation rather than this job: the gating
# row picks the tests back up automatically, and this job goes empty and can go
# with it.
# ---------------------------------------------------------------------------
e2e-api37-advisory:
name: E2E API 37 Media3 hardware transcode (advisory)
runs-on: ubuntu-latest
needs: ffmpeg
timeout-minutes: 60
continue-on-error: true
env:
E2E_LABEL: "37-media3-transcode"
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 }}-
- 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
env:
E2E_DISABLE_SYSTEM_UI: "1"
# The complement of the gating row's notAnnotation, off the same marker,
# so a test can never be excluded from both jobs or run in both.
E2E_EXTRA_GRADLE_ARGS: "-Pandroid.testInstrumentationRunnerArguments.annotation=org.libremediaconverter.FailsOnEmulatorApi37"
with:
# Every device pin below matches the gating row exactly, so a difference
# between the two jobs is the test selection and nothing else.
api-level: "37.0"
target: google_apis
arch: x86_64
profile: pixel_6
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
disk-size: 8G
ram-size: 2560M
script: bash .github/scripts/e2e-run.sh ${{ env.E2E_LABEL }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-report-api${{ env.E2E_LABEL }}
path: |
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
if-no-files-found: warn
# Uploaded always, and here it matters more than anywhere else in this file:
# this job is EXPECTED to be red, so the logcat is the only thing that says
# whether it is red for the known reason or for a new one.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-diagnostics-api${{ env.E2E_LABEL }}
path: |
${{ runner.temp }}/logcat-api${{ env.E2E_LABEL }}.txt
${{ runner.temp }}/diagnostics-api${{ env.E2E_LABEL }}.txt
if-no-files-found: warn
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-wedge-api${{ env.E2E_LABEL }}
path: ${{ runner.temp }}/wedge-diagnostics-api${{ env.E2E_LABEL }}.txt
if-no-files-found: ignore