name: CI # The other five workflows package, publish or release; none of them test # anything, so a green tick on this repo used to mean "the Arch package # built", which is not the question anyone was asking. This is the # workflow that gates. # # Both jobs were prototyped end to end in a bare ubuntu:24.04 container # before being written here, so every step below is a transcription of # something observed working rather than something expected to. # **A branch push and its PR are the same commit, and testing it twice # costs the only runner there is.** `branches: ['**']` here meant every # PR booked four runs — `check` and `e2e` for the branch push, then both # again for `refs/pull/N/head` — on a host with capacity 1, where the # queue is shared with an index build that can hold it for three hours. # # `pull_request` covers feature branches, and `main` is kept because a # post-merge run is the record of the trunk's health. Since main now # refuses direct pushes, that run happens exactly once per merge. # # The trade is explicit: a branch pushed with **no** PR open gets no CI. # That is consistent with the workflow this repo committed to — every # change goes through a PR — and the signal returns the moment one is # opened, on the same commit. on: push: branches: [main] pull_request: workflow_dispatch: # A newer push supersedes an older one on the same ref. Job 2 binds # :34115, so overlapping runs on one runner would fight over the port. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true env: GO_VERSION: '1.25.0' # Shared by all three Playwright consumers (@playwright/cli, e2e/'s # @playwright/test, frontend/'s Vitest provider). See the browsers # step in job 2 for why that is not the whole story. PLAYWRIGHT_BROWSERS_PATH: /cache/ms-playwright # Best-effort pnpm store reuse; pnpm reads npm_config_* for its own # config keys. If it ever stops honouring this we lose cache warmth # and nothing else. npm_config_store_dir: /cache/pnpm-store jobs: # ---------------------------------------------------------------- # # Job 1: everything that does not need a display. # # ---------------------------------------------------------------- # check: runs-on: ubuntu-latest container: # Not golang:1.25 — this job runs `make ui-test`, which is Vitest # *browser* mode and needs a Chromium and its system libraries # anyway, so the "fast job needs no browser" split does not hold. # Not the Playwright image either: e2e/ pins @playwright/test # ^1.56 and frontend/ pins playwright ^1.62, so a prebuilt browser # set matches at most one of them. Ubuntu 24.04 is also what # Playwright's WebKit build links against, which job 2 needs. image: ubuntu:24.04 # GOMODCACHE / GOCACHE / GOLANGCI_LINT_CACHE are already mounted # and exported for every job by the runner's container.options, so # only the Node-side caches are listed here. The runner's # valid_volumes allows anything under the cache root. volumes: - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool - /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store env: PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} SERVER_URL: ${{ github.server_url }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} DEBIAN_FRONTEND: noninteractive steps: - name: System packages run: | set -eu apt-get update -qq # libwebkitgtk-6.0-dev and libasound2-dev are not optional: # the app is cgo, and without alsa.pc oto/v3 fails at # `pkg-config --cflags -- alsa` before anything is compiled. # ubuntu:24.04 ships webkitgtk-6.0, which is what wails v3 # builds against by default. apt-get install -y -qq --no-install-recommends \ ca-certificates curl git jq build-essential pkg-config \ libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev ffmpeg # Cloned by hand rather than with actions/checkout: that is a JS # action and needs node inside the job container before any step # has had a chance to install it. Same approach as the other # other workflows in this directory. - name: Clone repo at this commit run: | set -eu git clone --quiet \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src git -C /src checkout --quiet --detach "$SHA" git -C /src log --oneline -1 # make bindings-check compares against the work tree, so git # has to be willing to operate on a directory it does not own. git config --global --add safe.directory /src # Conventional Commits. `.releaserc.yml` has always derived the # version from the commit type; until now nothing checked that the # type was one it recognises, so a malformed subject silently meant # "no release". BEFORE is the push's previous tip and is absent or # all-zeros for a new branch, in which case only the tip is linted. - name: Commit messages working-directory: /src env: BEFORE: ${{ github.event.before }} run: | set -eu if [ -n "${BEFORE:-}" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \ && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then make commit-check RANGE="$BEFORE..$SHA" else make commit-check fi - name: Go toolchain run: | set -eu if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then mkdir -p /cache/tool && rm -rf /cache/tool/go curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz fi echo "/cache/tool/go/bin" >> "$GITHUB_PATH" /cache/tool/go/bin/go version - name: Node toolchain run: | set -eu curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y -qq --no-install-recommends nodejs corepack enable node --version - name: Vitest provider browser working-directory: /src/frontend run: | set -eu pnpm install --frozen-lockfile npx playwright install --with-deps chromium # main.go embeds the built frontend (`//go:embed all:frontend/dist`), # so *every* Go typecheck needs it to exist first — lint, test and # bindings-check all fail with "pattern all:frontend/dist: no # matching files found" on a fresh clone. This never bites locally # because anyone who has run the app once has a dist/ lying around, # which is exactly why CI has to do it explicitly. - name: Build the frontend working-directory: /src/frontend run: pnpm build # `make lint` and `make test` each run all three build # configurations (app / indexbuild / dev) with matching tag sets. - name: Lint working-directory: /src run: make lint - name: Test working-directory: /src run: make test - name: Typecheck the frontend working-directory: /src/frontend run: npx tsc --noEmit # A backtick inside a comment in a css`` literal ends the literal. # tsc above does fail on it, with a message about CSSResult # pointing at a line of prose; this one names the cause. It runs # after tsc for exactly that reason — whichever fails, the log has # the sentence in it. - name: CSS template literals are intact if: ${{ !cancelled() }} working-directory: /src run: make css-check - name: Component and store suite working-directory: /src run: make ui-test # frontend/bindings is generated by `wails3`, not by `go generate`, # so the codegen pre-commit hook does not cover it. - name: Bindings are current working-directory: /src run: make bindings-check # Every `make ` named under .pi/**/*.md must exist, so an # agent is never sent at a command that was renamed away. - name: Documented make targets exist working-directory: /src run: make skill-check # ---------------------------------------------------------------- # # Job 2: the real app, headless. v3's `-tags server` needs no # # display, so the Xvfb this job used to wrap everything in is gone. # # `dbus-run-session` stays, for MPRIS. # # ---------------------------------------------------------------- # e2e: runs-on: ubuntu-latest needs: check container: image: ubuntu:24.04 volumes: - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool - /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store env: PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} SERVER_URL: ${{ github.server_url }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} DEBIAN_FRONTEND: noninteractive # The explore artifact is stubbed with a dead address, exactly as # scripts/seed-sandbox.sh does it. Serving a real cut-down # artifact would mean building one under the indexbuild tag from # dump state this runner does not have, and no spec asserts on # explore content, so it would buy nothing. Note that # dev-headless.sh does *not* set this itself — only seed-sandbox # does — so the run would otherwise fetch the real artifact over # the network. It is also worth ~8x on suite wall clock: the # testctl DB restore spec copies every table, and the real # artifact makes that table set enormous. YJ_CORE_INDEX_URL: 'http://127.0.0.1:1/none.tar.zst' steps: - name: System packages run: | set -eu apt-get update -qq apt-get install -y -qq --no-install-recommends \ ca-certificates curl git jq build-essential pkg-config \ libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \ dbus dbus-x11 ffmpeg libasound2t64 \ alsa-utils libasound2-plugins pulseaudio pulseaudio-utils - name: Clone repo at this commit run: | set -eu git clone --quiet \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src git -C /src checkout --quiet --detach "$SHA" git config --global --add safe.directory /src - name: Go toolchain run: | set -eu if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then mkdir -p /cache/tool && rm -rf /cache/tool/go curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz fi echo "/cache/tool/go/bin" >> "$GITHUB_PATH" - name: Node toolchain run: | set -eu curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y -qq --no-install-recommends nodejs corepack enable # @playwright/cli is gone with v2. seed-sandbox.sh drove the real # AddLibrary binding through a browser because `window.go` was the # only way in; v3 answers the same call over HTTP, so the seed is # curl now and needs no CLI, no second Chromium and no shared # PLAYWRIGHT_BROWSERS_PATH revision dance. - name: Browsers working-directory: /src/e2e run: | set -eu pnpm install --frozen-lockfile npx playwright install --with-deps chromium webkit # oto/v3 talks to libasound directly, and a container has no # PulseAudio socket to fall back on — so it needs a default device # that not only accepts audio but **paces** it, because the player's # position is derived from what has been consumed. # # ALSA's `null` plugin does not pace. It was used here on the # belief that it advances its pointer on a timer. Measured in # this exact image, through beep and oto with the same # `speaker.Init` arguments `player.InitSpeaker` uses: # # type null 3000 ms of audio consumed in 2.96 ms # pulse sink 3000 ms of audio consumed in 3762 ms # # A thousand times too fast. Every track finished instantly, the # position reset to zero, and three specs failed on a clock that # never moved — which is the whole of the e2e job's red history, # and it looked like a flake because `InitSpeaker` succeeds either # way (in ~3 ms, also either way). # # PulseAudio's null sink is timer-scheduled and does pace — the # 0.76 s over is the buffer draining, not a rate error; 12 s of # audio takes 13.5 s. Verified under the private session bus # dev-headless.sh runs the app in. It needs no system D-Bus and # no kernel module, which is why it is reachable from a container # at all. - name: Real-time audio sink run: | set -eu # --system because the job runs as root and PulseAudio refuses # to start as root any other way. adduser root pulse-access pulseaudio --system --daemonize --disallow-exit \ --exit-idle-time=-1 \ --load="module-null-sink sink_name=yellowjacket" printf 'pcm.!default { type pulse }\nctl.!default { type pulse }\n' \ > /etc/asound.conf pactl list short sinks # The sink is a dependency with a *rate*, so it is checked like # one. Without this the failure surfaces three steps later as # "the elapsed clock is 19 s adrift", which reads as an app bug # and cost two sessions of exactly that suspicion. - name: The sink plays at real time run: | set -eu ffmpeg -loglevel quiet -f lavfi -i "sine=frequency=440:duration=3" \ -ar 44100 /tmp/probe.wav # aplay rather than the app: this is a check on the *device*, # and it has to be able to fail before the app is built. start=$(date +%s%N) aplay -q /tmp/probe.wav ms=$(( ($(date +%s%N) - start) / 1000000 )) echo "3000 ms of audio took ${ms} ms" if [ "$ms" -lt 2000 ]; then echo "The default ALSA device is discarding audio rather than" \ "playing it. Every track will finish instantly and the" \ "player's position will never advance." >&2 exit 1 fi - name: Fixtures and seed working-directory: /src run: | set -eu make testdata # A seed is built by *running the app* and driving the real # AddLibrary binding — never by writing config.toml and DB # rows, which would be a second description of a valid YJ_HOME. make sandbox-seed NAME=default # dev-headless daemonises (writes .dev/app.pid and returns), which # is why Playwright's webServer cannot supervise it and why this is # a step of its own. e2e/'s globalSetup checks /__test/health. - name: Start the app headless working-directory: /src run: make dev-headless SEED=default - name: E2E — chromium working-directory: /src run: make e2e # Playwright's Linux WebKit links Ubuntu 24.04 libraries that Arch # does not provide, so this cannot run on a dev machine at all: CI # is the only place we get any signal about the WebKit2GTK renderer # we actually ship. Required rather than advisory because it was # measured green (19/19) in this exact container before being # enabled, and because nothing in e2e/ compares pixels — every # assertion is an event payload, a testid, an attribute or backend # state, so a WebKit failure here is an engine bug, not baseline # noise. It costs ~11 s. # # `if: !cancelled()` because without it a chromium failure skips # this step, and chromium has been failing on the container's # audio clock — so the run that was the *only* source of WebKit # signal quietly stopped producing any, and the plan spent a pass # treating "CI also runs WebKit" as true when the job log said # `conclusion: skipped`. - name: E2E — webkit if: ${{ !cancelled() }} working-directory: /src env: YJ_E2E_WEBKIT: '1' run: make e2e E2E_ARGS="--project=webkit" # The app log is the only place a hung binding call explains # itself, so put it in the job log where `gitea_ci job_logs` can # reach it without downloading an artifact. - name: App log on failure if: failure() working-directory: /src run: tail -n 200 .dev/app.log || true - name: Upload traces and screenshots if: failure() continue-on-error: true uses: actions/upload-artifact@v4 with: name: e2e-report-${{ github.run_id }} path: | /src/e2e/playwright-report/ /src/.dev/app.log retention-days: 7 - name: Stop the app if: always() working-directory: /src run: make dev-stop || true