fix(website): port newestPerPlatform to leaderboard so multi-platform… #218
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Nix build | |
| # nix-check.yml verifies that npmDepsHash still matches the lockfile. That is a | |
| # necessary condition for `nix build` to succeed, not a sufficient one, and the | |
| # distinction has never been tested: nothing in CI has ever actually built | |
| # nix/package.nix. `grep -rn "nix build" .github/workflows` returns one hit, and | |
| # it is inside a comment. | |
| # | |
| # So the derivation is verified the way its npmDepsHash was before its sibling | |
| # existed — by a user, after it breaks. The failure mode is worse here, because | |
| # the build can succeed while producing a reduced application: buildPhase runs | |
| # `vite build` alone, while package.json's build:linux also chains the PipeWire | |
| # helper, the compositor addon and an ffmpeg download. The native components are | |
| # resolved at runtime behind existence checks, so their absence degrades the app | |
| # instead of failing it. A green `nix build` therefore proves the derivation | |
| # compiles, and nothing about whether the result can record or export. | |
| # | |
| # Hence two steps rather than one: build, then run the headless CLI against the | |
| # artefact. The build catches drift in what installPhase copies; the smoke test | |
| # catches the gap between "compiles" and "runs". | |
| # | |
| # Deliberately not on every pull request yet. Nobody has measured what this | |
| # costs on a runner, and a check whose duration is unknown is not one to put in | |
| # front of every merge. Promote it once the schedule has reported a few times. | |
| on: | |
| workflow_dispatch: | |
| # The job that actually builds the derivation now runs on the pull requests that | |
| # can break it. It did not, and the gap was not academic: `nix-check.yml` only | |
| # compares npmDepsHash, so a PR rewriting the addon's source filter, its RPATH | |
| # handling or its symbols.map went green on ~18 checks without one of them | |
| # building it, and the first real signal arrived on main half an hour after the | |
| # merge. #371 shipped a change to `nix/compositor-view.nix` that way. | |
| # | |
| # Path-filtered rather than universal: this takes about half an hour, and a PR | |
| # that touches none of these files cannot change what it produces. | |
| pull_request: | |
| paths: | |
| - flake.nix | |
| - flake.lock | |
| - nix/** | |
| - crates/** | |
| - package-lock.json | |
| # Including itself, or a PR that only edits this file gets no validation of | |
| # the change it is making -- the same rule nix-check.yml already follows. | |
| - .github/workflows/nix-build.yml | |
| push: | |
| branches: [main] | |
| schedule: | |
| # Mondays, 06:00 UTC. Drift here comes from the lockfile and from the app's | |
| # own build requirements, neither of which respects a release cadence. | |
| - cron: "0 6 * * 1" | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # github.ref is refs/heads/main for the schedule as well as for a push to main, | |
| # so keying on it alone puts both triggers in one group -- and with | |
| # cancel-in-progress the Monday run is killed by any merge landing inside its | |
| # half-hour window. A cancelled run is not a failure, so nothing goes red and | |
| # the periodic report this workflow exists to produce silently stops arriving. | |
| # Keying on the event as well keeps a merge train collapsing to its latest push | |
| # without letting it cancel the schedule. | |
| group: nix-build-${{ github.event_name }}-${{ github.ref }} | |
| # false, where a PR-branch check would want true. This job takes half an hour | |
| # and main takes merges minutes apart, so cancel-in-progress meant each merge | |
| # evicted the previous run before it finished: the first four runs on main were | |
| # cancelled at 21m38s, 25s, 7m05s and then one that only survived because the | |
| # merges happened to stop. A cancelled run is not a failure either, so the | |
| # branch showed nothing while the workflow reported nothing. | |
| # | |
| # What this buys is that a started run finishes, not that every merge is | |
| # verified: GitHub holds a single pending entry per group and a newer push | |
| # replaces it, so merges landing while a run is in flight still go unbuilt. | |
| # That is the affordable half. Verifying each merge would need a queue this | |
| # workflow does not have, and is not worth it for a half-hour job whose purpose | |
| # is catching drift rather than gating a commit. | |
| # | |
| # On a pull request the opposite is right: a new push makes the previous run | |
| # answer a question nobody is asking any more, and at half an hour each they | |
| # would pile up. `github.ref` is the PR's merge ref, so the group is per-PR. | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| build: | |
| name: nix build .#openscreen | |
| runs-on: ubuntu-latest | |
| # Set from the smoke step's own arithmetic, not from a build measurement. | |
| # `timeout --kill-after` is additive to wall clock, so each run_cli costs up | |
| # to CLI_TIMEOUT + 10: two probes at 130 + one --help at 130 + 5x130 for | |
| # sources + 3x(130 + 190) for record/export = 2000s, a shade over 33 minutes | |
| # before a single second of build. | |
| # | |
| # The earlier 30 came from "two minutes, cold, 1.7G closure", measured when | |
| # the script had four bounded invocations and the derivation had neither the | |
| # Rust/wgpu compositor nor an ffmpeg override that cannot be substituted from | |
| # cache.nixos.org and so compiles from source. Both landed afterwards and the | |
| # budget was never revisited, which left the inner bounds summing to more than | |
| # the outer one -- the job would be cancelled mid-step, printing no verdict, | |
| # which is the exact "sat on a runner and learned nothing" outcome the bounds | |
| # were added to prevent. | |
| timeout-minutes: 60 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # checkout writes the job token into .git/config by default. This job | |
| # builds the checked-out tree and then *runs the produced binary*, so a | |
| # persisted token sits within reach of that code. Nothing here pushes. | |
| persist-credentials: false | |
| - uses: cachix/install-nix-action@v27 | |
| with: | |
| nix_path: nixpkgs=channel:nixos-unstable | |
| extra_nix_config: | | |
| experimental-features = nix-command flakes | |
| # The compositor renders through wgpu, so exporting needs a Vulkan ICD. | |
| # A GitHub runner has no GPU and no software rasteriser, and the app says | |
| # so itself when it gets there: | |
| # | |
| # aucun pilote Vulkan sur cet hote ... le repli logiciel a echoue aussi | |
| # (aucun rasteriseur logiciel Vulkan (lavapipe)) | |
| # | |
| # apt was the obvious source and was the wrong one: installing | |
| # mesa-vulkan-drivers on this image left exactly one ICD behind, | |
| # asahi_icd.json -- the Apple Silicon driver, on an x86 runner. Nix is | |
| # already here and its mesa carries lavapipe, so take it from there and | |
| # verify what landed rather than assuming a package's contents twice. | |
| # | |
| # Note what this is: the runner acquiring a capability an ordinary desktop | |
| # already has, in the same category as its missing dbus. The derivation | |
| # gains nothing -- a nix package that forced its own rasteriser would put | |
| # every user with a real GPU into software rendering. | |
| - name: Provide a software Vulkan rasteriser | |
| run: | | |
| set -euo pipefail | |
| # --inputs-from . resolves nixpkgs through this flake's lock rather than | |
| # the ambient registry. Without it the ICD comes from whatever | |
| # nixpkgs-unstable happens to be current that day while the package under | |
| # test is built from the pinned rev, so the loader and the driver are | |
| # never the same pair twice -- a drift source injected into the very | |
| # check that exists to catch drift, and one that can redden the export | |
| # assertion for a reason unrelated to nix/package.nix. | |
| MESA=$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#mesa) | |
| # --print-out-paths prints one line per output. Keep the first, or a | |
| # multi-output mesa turns $MESA into a newline-joined argument and the | |
| # find below fails on a path that does not exist -- taking the step down | |
| # before the diagnostic branch can say why. | |
| MESA=${MESA%%$'\n'*} | |
| echo "mesa: $MESA" | |
| # -print -quit rather than `| head -1`: find stops itself at the first | |
| # match, so there is no pipe whose status pipefail can propagate into the | |
| # assignment and no SIGPIPE to reason about. | |
| ICD=$(find "$MESA" -name 'lvp_icd*.json' -print -quit) | |
| if [ -z "$ICD" ]; then | |
| echo "ICDs actually present:" | |
| find "$MESA" -name '*_icd*.json' || true | |
| echo "::error::nixpkgs#mesa carries no lavapipe ICD; the export test cannot run here" | |
| exit 1 | |
| fi | |
| echo "lavapipe ICD: $ICD" | |
| echo "VK_ICD_FILENAMES=$ICD" >> "$GITHUB_ENV" | |
| - name: Build | |
| run: nix build .#openscreen --print-build-logs | |
| - name: Report closure size | |
| run: | | |
| set -euo pipefail | |
| echo "closure: $(nix path-info --closure-size --human-readable ./result | tail -1)" | |
| ls -la result/bin/ | |
| # The native components are resolved at runtime behind existence checks, so | |
| # a wrapper pointing at a path that is not there degrades the app instead of | |
| # failing it -- silently, which is the failure mode this whole workflow was | |
| # written for. The smoke test below covers the compositor addon by | |
| # exercising export; nothing exercises the PipeWire helper, because that | |
| # needs a Wayland portal this runner does not have. Asserting the file | |
| # exists and is executable is the part that can be checked here, and it is | |
| # the part that actually broke when a --set pointed somewhere wrong. | |
| - name: Check the wrapper's native components | |
| run: | | |
| set -euo pipefail | |
| missing=0 | |
| wrapper_value() { | |
| sed -nE "s/^export $1='(.*)'\$/\1/p" result/bin/openscreen | tail -1 | |
| } | |
| # -x, not -e, for the ones that get spawned. The app agrees: the helper | |
| # lookup in pipeWireCursorRecordingSession.ts requires X_OK before it | |
| # accepts a candidate, so a present-but-not-executable file would pass a | |
| # mere existence check here and be rejected at runtime. The compositor | |
| # addon is require()'d rather than spawned, so readable is its bar. | |
| for entry in \ | |
| "OPENSCREEN_FFMPEG_PATH:-x" \ | |
| "OPENSCREEN_COMPOSITOR_VIEW_NODE:-r" \ | |
| "OPENSCREEN_LINUX_CURSOR_HELPER_EXE:-x" \ | |
| "OPENSCREEN_WHISPER_SERVER_EXE:-x"; do | |
| var=${entry%:*} | |
| test=${entry##*:} | |
| # Read the value the wrapper exports rather than re-deriving it here, | |
| # so this checks the artefact and not the recipe. | |
| path=$(wrapper_value "$var") | |
| if [ -z "$path" ]; then | |
| echo "::error::$var is not set by the wrapper" | |
| missing=$((missing + 1)) | |
| continue | |
| fi | |
| if [ ! "$test" "$path" ]; then | |
| echo "::error::$var points at $path, which fails $test" | |
| missing=$((missing + 1)) | |
| continue | |
| fi | |
| echo "$var -> $path ($test)" | |
| done | |
| # ldd was the wrong instrument here, and it passed for the wrong reason: | |
| # it resolves DT_NEEDED entries, and libpipewire is reached by dlopen, so | |
| # it is never a DT_NEEDED and never appears. The check would have gone | |
| # green with the hand-added RPATH missing entirely -- validating | |
| # everything except the one thing this packaging adds. | |
| # | |
| # Read the RPATH out of the object instead, and require that one of its | |
| # directories actually holds the soname handed to dlopen. | |
| # | |
| # Both components have one. The helper's libpipewire entry is the one | |
| # that caught a real regression; the addon's libvulkan entry is the same | |
| # construction and was silently being stripped too, unnoticed because | |
| # ubuntu-latest has a system libvulkan that satisfies the dlopen anyway. | |
| # That is exactly why it needs asserting rather than exercising: the | |
| # runner cannot reproduce the host where it matters. | |
| check_dlopen_rpath() { | |
| local what="$1" obj="$2" soname="$3" | |
| [ -n "$obj" ] || return 0 | |
| echo "--- $what dlopen contract ---" | |
| # DT_RPATH or DT_RUNPATH: the derivations ask for the former, but read | |
| # whichever is there rather than asserting which, so a silent | |
| # conversion is reported instead of looking like an absent RPATH. | |
| local rpath | |
| rpath=$(readelf -d "$obj" | sed -nE 's/.*\((RPATH|RUNPATH)\).*\[(.*)\]/\2/p' | tail -1) | |
| echo "rpath: ${rpath:-<none>}" | |
| local origin found="" dirs dir | |
| origin=$(dirname "$obj") | |
| IFS=: read -ra dirs <<<"$rpath" | |
| for dir in ${dirs[@]+"${dirs[@]}"}; do | |
| # $ORIGIN is relative to the object; the ffmpeg entries use it. | |
| dir=${dir//\$ORIGIN/$origin} | |
| if [ -e "$dir/$soname" ]; then | |
| found="$dir" | |
| break | |
| fi | |
| done | |
| if [ -z "$found" ]; then | |
| echo "::error::no RPATH entry of the $what holds $soname; its dlopen will fail on any host without an ld.so.cache" | |
| missing=$((missing + 1)) | |
| else | |
| echo "$soname resolves from $found" | |
| fi | |
| # Unresolved DT_NEEDED entries are a different fault, and this is | |
| # where they would show. | |
| if ldd "$obj" | grep -q "not found"; then | |
| echo "::error::the $what has unresolved DT_NEEDED libraries" | |
| ldd "$obj" | grep "not found" | |
| missing=$((missing + 1)) | |
| fi | |
| } | |
| check_dlopen_rpath "PipeWire helper" \ | |
| "$(wrapper_value OPENSCREEN_LINUX_CURSOR_HELPER_EXE)" libpipewire-0.3.so.0 | |
| check_dlopen_rpath "compositor addon" \ | |
| "$(wrapper_value OPENSCREEN_COMPOSITOR_VIEW_NODE)" libvulkan.so.1 | |
| [ "$missing" -eq 0 ] | |
| # Electron initialises Chromium even for the headless CLI, so it needs a | |
| # display server present. xvfb ships on ubuntu-latest. | |
| # | |
| # Two attempts, because "does the artefact run" and "can Chromium sandbox | |
| # here" are different questions and only the first is about the build. | |
| # chrome-sandbox has to be setuid root; a nix store path cannot be. NixOS | |
| # answers that with security.chromiumSuidSandbox.enable, which a non-NixOS | |
| # host running nix does not have, and Chromium's fallback -- unprivileged | |
| # user namespaces -- is what Ubuntu restricts by default since 23.10. | |
| # | |
| # So a failure of the first attempt alone says nothing about the | |
| # derivation. A failure of both does. | |
| - name: Smoke-test the built binary | |
| run: | | |
| set -euo pipefail | |
| # What this host actually gives X11 and Chromium, rather than what | |
| # Debian's copy of xvfb-run says it would. Both capture paths die in | |
| # screen_capturer_x11.cc "Failed to initialize pixel buffer", and the | |
| # usual suspects are a shallow screen depth and a small /dev/shm. | |
| echo "--- capture-relevant environment ---" | |
| echo "xvfb-run default args: $(grep -m1 '^XVFBARGS=' "$(command -v xvfb-run)" || echo unknown)" | |
| echo "/dev/shm: $(df -h /dev/shm | tail -1)" | |
| echo "--- sandbox-relevant kernel settings ---" | |
| echo "unprivileged_userns_clone: $(sysctl -n kernel.unprivileged_userns_clone 2>/dev/null || echo 'n/a')" | |
| echo "apparmor_restrict_unprivileged_userns: $(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo 'n/a')" | |
| # Probe for the sandbox mode this host can run, then assert against it. | |
| # The probe decides how to invoke; it is not itself the test. | |
| # Every invocation is bounded. A CLI that never returns is a failure | |
| # mode this workflow has already produced once, and an unbounded | |
| # command turns it into a full runner slot spent learning nothing. | |
| # Both of these were guesses at the X11 capture failure and the | |
| # measurements above disproved both: this xvfb-run already defaults to | |
| # 24-bit, and /dev/shm is 7.9G and empty. They stay because they are | |
| # harmless and explicit beats inherited, but neither is the fix, and | |
| # the capture failure remains unexplained. | |
| XVFB_SCREEN="-screen 0 1920x1080x24" | |
| CHROME_FLAGS="--disable-dev-shm-usage" | |
| # Counted, because position is the variable under test and it is not | |
| # fixed. The sandbox probe costs one invocation or two depending on the | |
| # host, and the record loop breaks on first success, so it costs anywhere | |
| # between two and six. An aggregate success rate therefore cannot be | |
| # attributed to position on its own -- the workload ahead of it moved | |
| # too. Labelling every block with the invocation it starts at is what | |
| # makes the rates comparable across runs; reading them unlabelled is the | |
| # mistake this counter exists to prevent. | |
| RUN_CLI_N=0 | |
| run_cli() { | |
| RUN_CLI_N=$((RUN_CLI_N + 1)) | |
| timeout --signal=TERM --kill-after=10s "${CLI_TIMEOUT:-120}" xvfb-run -a -s "$XVFB_SCREEN" ./result/bin/openscreen "$@" | |
| } | |
| SANDBOX="" | |
| echo "--- probe: as shipped ---" | |
| if run_cli --help >/dev/null 2>&1; then | |
| echo "Chromium sandboxes normally here." | |
| elif run_cli --no-sandbox --help >/dev/null 2>&1; then | |
| SANDBOX="--no-sandbox" | |
| echo "::warning::The artefact runs only with --no-sandbox. chrome-sandbox cannot be setuid inside the nix store, so this package has no sandbox story on non-NixOS hosts." | |
| else | |
| echo "::error::The artefact does not run, with or without the Chromium sandbox." | |
| exit 1 | |
| fi | |
| echo "--- openscreen --help ---" | |
| run_cli $SANDBOX $CHROME_FLAGS --help | |
| # `sources` enumerates displays, windows and microphones, so it | |
| # exercises the capture stack rather than proving that a usage string | |
| # can be printed. | |
| # | |
| # Read through -o rather than stdout. Ubuntu's xvfb-run folds the | |
| # command's stderr into its stdout -- established by probing it here, | |
| # after an earlier reading of Debian's copy of the script said | |
| # otherwise -- so Chromium's startup complaints about dbus and OpenGL | |
| # arrive mixed into what a parser would read. The CLI is not at fault | |
| # and stdout needs no fixing; the wrapper is simply not something a | |
| # caller controls, which is the whole reason -o exists. | |
| # The hang reproduces about half the time, so a single invocation | |
| # proves nothing either way. Five per run, sharing one build: the | |
| # build is the expensive part and it is already done by here. | |
| # | |
| # OPENSCREEN_DIAGNOSTIC turns on the timestamped startup milestones. | |
| # Three of six earlier runs stopped here with the renderer emitting | |
| # nothing at all, so the question is how far the main process got, and | |
| # the last milestone before the kill is the answer. | |
| ATTEMPTS=5 | |
| # Named once so the invocation and the message that reports a kill | |
| # cannot drift apart -- they already had. | |
| SOURCES_TIMEOUT=120 | |
| HUNG=0 | |
| OK=0 | |
| FAILED=0 | |
| LAST_OK="" | |
| for i in $(seq 1 "$ATTEMPTS"); do | |
| echo "=== attempt $i/$ATTEMPTS (run_cli #$((RUN_CLI_N + 1))) ===" | |
| RC=0 | |
| # 120, matching record: the renderer's own bounds can legitimately | |
| # spend 35s here (20s on getSources, then up to 3x5s on the audio | |
| # path), and 60 left barely 25s for a cold Electron start under Xvfb | |
| # from a 1.7G closure -- so the outer kill could fire while every | |
| # internal bound was working. | |
| # 2>&1, like record and export: the probe above established that this | |
| # host's xvfb-run folds stderr into stdout anyway, so the separate .err | |
| # capture was a file nothing could ever read. | |
| CLI_TIMEOUT=$SOURCES_TIMEOUT OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS sources -o "/tmp/sources.$i.json" >"/tmp/sources.$i.out" 2>&1 || RC=$? | |
| # Before the branching, so a success reports its duration too. The | |
| # measurement was added to compare this path against record's, and the | |
| # first run to carry it printed only record's -- the grep lived in a | |
| # failure branch, so the successful side, which is the interesting one, | |
| # went into a scratch file and stayed there. | |
| grep -a "get-sources\]" "/tmp/sources.$i.out" || true | |
| # Every non-zero outcome is a failure. The kill is tracked on top of | |
| # that rather than instead of it, because it says something different: | |
| # the run never got far enough to report a reason at all. | |
| if [ "$RC" -eq 124 ] || [ "$RC" -eq 137 ]; then | |
| FAILED=$((FAILED + 1)) | |
| HUNG=$((HUNG + 1)) | |
| echo "HUNG after ${SOURCES_TIMEOUT}s. Milestones reached:" | |
| # Read from stdout, not stderr. The probe above shows this host's | |
| # xvfb-run folds the command's stderr into stdout, so the stderr | |
| # capture is empty and every milestone lands in the other file. | |
| grep -a milestone "/tmp/sources.$i.out" || echo " (none at all)" | |
| elif [ "$RC" -eq 0 ]; then | |
| OK=$((OK + 1)) | |
| LAST_OK="/tmp/sources.$i.json" | |
| echo "succeeded. Last milestone:" | |
| grep -a milestone "/tmp/sources.$i.out" | tail -1 || echo " (none)" | |
| else | |
| # Bounded failure, not a hang: the CLI gave up and said why. Its -o | |
| # file does not exist, so it must not become LAST_OK -- an earlier | |
| # version assumed any non-timeout exit meant success and then tried | |
| # to read a file that was never written. | |
| FAILED=$((FAILED + 1)) | |
| echo "failed with rc=$RC. Last milestone:" | |
| grep -a milestone "/tmp/sources.$i.out" | tail -1 || echo " (none)" | |
| echo "reported error:" | |
| grep -a "Error:" "/tmp/sources.$i.out" | tail -2 || true | |
| fi | |
| done | |
| echo "=== $ATTEMPTS attempts: $OK ok, $FAILED failed ($HUNG of them killed on timeout) ===" | |
| # A run where nothing hung still has to prove the -o channel works. | |
| if [ -n "$LAST_OK" ]; then | |
| echo "--- the -o file from the last attempt that returned ---" | |
| cat "$LAST_OK" | |
| SOURCES_FILE="$LAST_OK" python3 <<'PY' | |
| import json, os | |
| doc = json.load(open(os.environ["SOURCES_FILE"])) | |
| missing = [k for k in ("displays", "windows", "microphones") if k not in doc] | |
| if missing: | |
| raise SystemExit(f"-o file is missing {missing}") | |
| print(f"displays={len(doc['displays'])} windows={len(doc['windows'])} microphones={len(doc['microphones'])}") | |
| PY | |
| fi | |
| # Noted, not acted on. Enumeration is flaky here and the export | |
| # assertion below is the more | |
| # important question; a flaky attempt costs an annotation rather than | |
| # the answer. Verdict at the end, once both have had their say. | |
| if [ "$HUNG" -gt 0 ]; then | |
| echo "::warning::openscreen sources had to be killed on $HUNG of $ATTEMPTS attempts. Check the milestones above for how far it got before the outer bound fired." | |
| fi | |
| if [ "$FAILED" -gt 0 ]; then | |
| echo "::warning::openscreen sources failed on $FAILED of $ATTEMPTS attempts. The capture path is not reliable on this host." | |
| fi | |
| # The real acceptance test. Everything above proves the package starts | |
| # and can answer an enumeration call; none of it touches the compositor | |
| # addon, which is what actually renders output. | |
| # | |
| # It used to record two seconds and export the result. That never once | |
| # worked here. `record` needs a display index and this host has no | |
| # display to give: Chromium's X11 capturer logs | |
| # "screen_capturer_x11.cc: Failed to initialize pixel buffer", `sources` | |
| # answers with `displays: []`, and record dies on "Display index 0 not | |
| # found (0 screen(s) available)". So export never ran, and the step that | |
| # exists to vouch for the addon vouched for nothing -- runs 32707512544 | |
| # (24/08) and 32827253816 (25/08) failed exactly here, in the 34-36 | |
| # minutes they took to get past the build. | |
| # | |
| # Measured before rewriting it, against the built artefact under Xvfb: | |
| # with `xvfb-run -a` as this workflow invokes it, 4 runs in 10 saw a | |
| # display; starting Xvfb by hand and polling xdpyinfo until the server | |
| # answered before launching the app, 3 in 10. So it is not the startup | |
| # race it looks like -- waiting for the server changes nothing -- and | |
| # whatever it is lives inside Chromium's X11 capturer. On this runner it | |
| # comes up zero every time rather than a third of the time. | |
| # | |
| # So the input stops being a recording. ffmpeg synthesises two seconds of | |
| # video, a three-line project points at it, and export renders that. | |
| # Identical in what it proves -- the packaged compositor addon loads, | |
| # decodes, composes through Vulkan and muxes an MP4 -- and it asks for no | |
| # capability a headless runner is ever going to have. Measured at 6/6 | |
| # locally where record measured 4/10. | |
| # | |
| # Capture is still worth watching, so one probe still runs. It is | |
| # informational: it cannot pass here, and nothing gates on it. | |
| echo "--- capture probe (informational; run_cli #$((RUN_CLI_N + 1))) ---" | |
| RC=0 | |
| CLI_TIMEOUT=120 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS record --duration 2 --project /tmp/probe.openscreen >/tmp/probe.out 2>&1 || RC=$? | |
| if [ "$RC" -eq 0 ] && [ -f /tmp/probe.openscreen ]; then | |
| echo "::warning::record worked on this runner. Capture is no longer broken here -- see whether the export check below should go back to using a real recording." | |
| else | |
| echo "capture still unavailable (rc=$RC); last lines:" | |
| tail -3 /tmp/probe.out || true | |
| fi | |
| echo "--- synthesise a clip and export it (first run_cli here is #$((RUN_CLI_N + 1))) ---" | |
| # From the flake's own nixpkgs, for the same reason the Vulkan ICD is: | |
| # the ambient registry drifts, and a decoder that is never the same twice | |
| # is drift injected into a check that exists to catch it. | |
| FFMPEG=$(nix build --no-link --print-out-paths --inputs-from . nixpkgs#ffmpeg-headless) | |
| FFMPEG=${FFMPEG%%$'\n'*} | |
| echo "ffmpeg: $FFMPEG" | |
| # H.264 in MP4 with an AAC track: the shape a real recording arrives in, | |
| # so the export walks its ordinary decode path rather than a special one. | |
| "$FFMPEG/bin/ffmpeg" -loglevel error -y \ | |
| -f lavfi -i "testsrc2=size=1280x720:rate=30" \ | |
| -f lavfi -i "sine=frequency=440:sample_rate=48000" \ | |
| -t 2 -pix_fmt yuv420p -c:v libx264 -c:a aac -shortest /tmp/demo-src.mp4 | |
| ls -l /tmp/demo-src.mp4 | |
| # The whole project format the exporter needs: a media path and an empty | |
| # editor, which normalises to a single full-length clip. This is what | |
| # `record --project` writes, minus the parts a recording fills in. | |
| cat > /tmp/demo.openscreen <<'JSON' | |
| { | |
| "version": 2, | |
| "media": { "screenVideoPath": "/tmp/demo-src.mp4" }, | |
| "editor": {} | |
| } | |
| JSON | |
| # Parse it back before handing it over, so a future edit that breaks the | |
| # JSON fails here with a parse error rather than 300 s later as an | |
| # export that could not read its project. | |
| python3 -c "import json;json.load(open('/tmp/demo.openscreen'))" | |
| cat /tmp/demo.openscreen | |
| echo "--- openscreen info ---" | |
| CLI_TIMEOUT=120 run_cli $SANDBOX $CHROME_FLAGS info /tmp/demo.openscreen || true | |
| rm -f /tmp/demo.mp4 | |
| RC=0 | |
| CLI_TIMEOUT=300 OPENSCREEN_DIAGNOSTIC=1 run_cli $SANDBOX $CHROME_FLAGS export /tmp/demo.openscreen -o /tmp/demo.mp4 >/tmp/exp.out 2>&1 || RC=$? | |
| EXPORT_OK=0 | |
| if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.mp4 ]; then | |
| echo "::error::export failed (rc=$RC). The compositor addon is packaged and the export path does not work." | |
| tail -25 /tmp/exp.out || true | |
| else | |
| SIZE=$(wc -c < /tmp/demo.mp4) | |
| # An MP4 opens with a 4-byte length then 'ftyp'. A zero-length or | |
| # truncated file would otherwise pass a mere existence check. | |
| MAGIC=$(dd if=/tmp/demo.mp4 bs=1 skip=4 count=4 2>/dev/null || true) | |
| echo "exported $SIZE bytes, magic at offset 4: $MAGIC" | |
| if [ "$MAGIC" != "ftyp" ]; then | |
| echo "::error::output is not an MP4 (no ftyp box)" | |
| tail -25 /tmp/exp.out || true | |
| elif [ "$SIZE" -lt 10000 ]; then | |
| echo "::error::MP4 is only $SIZE bytes, too small to hold two seconds of video" | |
| tail -25 /tmp/exp.out || true | |
| else | |
| echo "Export works: $SIZE bytes of MP4." | |
| EXPORT_OK=1 | |
| fi | |
| fi | |
| # One verdict, after both questions have been asked. Enumeration being | |
| # flaky must not hide whether export works, which is the whole point of | |
| # having packaged the compositor addon. | |
| # | |
| # The gate is "did enumeration ever answer" and "does export work", not | |
| # "did all five attempts pass". Requiring FAILED -eq 0 made the job red | |
| # by construction: the standing numbers on this runner are 1/5, 3/5 and | |
| # 4/5 ok, so a run where export is perfect and four enumerations succeed | |
| # still failed. A post-merge check that is red by design is one that gets | |
| # muted, which costs more than the flakiness it was reporting. The | |
| # per-attempt warnings above keep that flakiness visible without letting | |
| # it decide the build; tighten this to $ATTEMPTS once the capture failure | |
| # is understood and fixed. | |
| # | |
| # Export, on the other hand, is a hard gate on every trigger again. It no | |
| # longer depends on a capability this host does not have, so there is | |
| # nothing left to excuse: if it fails now, the package is broken. | |
| echo "=== verdict: enumeration $OK/$ATTEMPTS ok, export $EXPORT_OK, $RUN_CLI_N run_cli invocations ===" | |
| if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -eq 0 ]; then | |
| exit 1 | |
| fi |