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 <target>` 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
