Files
yellowjacket/.gitea/workflows/ci.yml
T
yonlu e67462ab53 ci: lint every commit a PR would merge, not just its tip
Gitea leaves `github.event.before` empty on a `pull_request`, so the
Commit messages step fell through to bare `make commit-check`, which
lints `git log -1` -- the tip alone.  Every other commit the branch
would bring was first examined by *main's* post-merge run, so a green
PR stopped being true after the merge, and it happened twice: PR #245
merged a 75-char subject its own CI never saw.

The PR's base is the stand-in.  `base.sha..head` lints the PR's own
commits because base advances on main, so the commits the branch shares
with it stay reachable from it and drop out of the range.

Both payload fields are handed to the shell rather than chosen in an
expression: `github.event.issue.number` in unclaim.yml is this repo's
proof that payload fields resolve, and `github.event` is the webhook
body unmarshalled into a map, so `pull_request.base.sha` comes from
Gitea's own `PRBranchInfo.Sha`.  The shell then falls back to today's
behaviour for a dispatch run, an all-zeros push, or a base commit the
clone does not have -- so the worst case is the fix not taking effect
rather than a broken job.

Verified locally against the report's own evidence: at 68e7edb8 the old
invocation passes ("HEAD is well-formed") while the range catches
a3b5b437 at 75 chars, which is what main's post-merge run did.  All
four event shapes were exercised against the new snippet.  The
end-to-end proof is the next PR with an over-length commit that is not
its tip.

Closes #254
2026-09-23 07:50:59 -04:00

422 lines
18 KiB
YAML

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".
#
# **On a `pull_request` there is no `before`.** Gitea leaves
# `github.event.before` empty for one, so this step fell through to
# bare `make commit-check`, which lints `git log -1` — the tip
# alone. Every other commit the branch would bring was first
# examined by *main's* post-merge run, which is a green PR that
# stops being true after the merge, and which happened twice (#254).
# The PR's base is the stand-in: the range below already excludes
# what the base shares with the branch, because base advances on
# main and those commits stay reachable from it.
#
# Both are handed to the shell rather than chosen in an expression:
# `github.event.issue.number` in unclaim.yml is this repo's proof
# that payload fields resolve, and the shell then falls back to
# today's behaviour for a dispatch run or a missing field instead of
# depending on how `&&`/`||` treat an absent context.
- name: Commit messages
working-directory: /src
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -eu
BEFORE="${PR_BASE:-${PUSH_BEFORE:-}}"
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