Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.
The point of the migration is one file. backend/events/emit.go probed
ctx.Value("events") — a v2-*private* context key — to decide whether
emitting was safe, because runtime.EventsEmit called log.Fatalf on a
context without the runtime and took the process down with it. v3's
emit takes no context, so that is now application.Get() == nil. D1
held: events.Emit keeps its ctx as the WithSink test seam, and all 45
call sites and 7 test files are untouched.
The bootstrap splits into application.New + Window.NewWithOptions +
Run. Ten bound services implement ServiceStartup instead of being
handed a context by hand from OnStartup, which also stops ten
SetContext methods being exported as bindings. jobs.Registry and
explore.SearchIndex keep theirs — neither is bound, so converting them
would be churn for no binding removed.
Four things differed from the plan and are written up in it: GPU policy
moved to the per-window LinuxWindow options rather than surviving on
LinuxOptions; there is no OnStartup/OnDomReady option, so app-level
wiring hangs off ApplicationStarted; application.NewService is generic,
so FEBindings []any could not survive (the binding generator is a
static analyser and would have seen nothing); and the quit veto had to
be restructured, because v3's dialog answers on a callback rather than
returning the button, so ShouldQuit vetoes, asks, and quits again from
the callback.
Window state saving moves to a WindowClosing hook — the size has to be
read while the window still exists, and v3's OnShutdown has neither
context nor window. backend/logging is deleted rather than ported:
v3 takes a *slog.Logger directly, so the v2 logger.Logger adapter had
no caller left.
Phase 1's tail rides along, now that it can: the Makefile's wails
invocations, all 50 webkit2_41 sites, lefthook, both packaging recipes
and ci.yml's apt lists. v3 builds against GTK4 + WebKitGTK 6.0, which
Arch and ubuntu:24.04 both ship, so the tag is a deletion rather than
a translation.
Phase 4 is next and the branch is not usable until it lands: the app
builds, but frontend/wailsjs/ is v2's tree and nothing regenerates it,
so the frontend cannot reach the backend yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
396 lines
17 KiB
YAML
396 lines
17 KiB
YAML
name: CI
|
|
|
|
# The other three workflows package and publish; 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.
|
|
|
|
on:
|
|
push:
|
|
branches: ['**']
|
|
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
|
|
# three 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/wailsjs is generated by `wails`, 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, under a virtual display. #
|
|
# ---------------------------------------------------------------- #
|
|
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 \
|
|
xvfb 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
|
|
|
|
# scripts/seed-sandbox.sh drives the real AddLibrary binding
|
|
# through playwright-cli, so the CLI has to be on PATH.
|
|
- name: Playwright CLI
|
|
run: npm install -g @playwright/cli
|
|
|
|
- name: Browsers
|
|
working-directory: /src/e2e
|
|
run: |
|
|
set -eu
|
|
# PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the
|
|
# *revisions*: @playwright/cli bundles its own playwright-core
|
|
# pinned to a different Chromium build than @playwright/test,
|
|
# so each installs its own into the shared directory. Drop
|
|
# either line and the other fails with "Browser chromium is
|
|
# not installed; expected executable at ...".
|
|
pnpm install --frozen-lockfile
|
|
npx playwright install --with-deps chromium webkit
|
|
playwright-cli install-browser chromium
|
|
|
|
# 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 and
|
|
# Xvfb that 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
|