From 8ec6785e2bfb392a3c79fa436652610511e80bf5 Mon Sep 17 00:00:00 2001 From: "fizzy-macos[bot]" <347071198+fizzy-macos[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:06:19 -0500 Subject: [PATCH] qa: scripts/qa-build keeps a Debug QA build of main and of PRs A script over scripts/zb that builds origin/main (or a pull request) in Debug, builds the pinned pixi, drive and example-plugin against that tree's SDK, installs each build with a seeded QA profile (fixtures: text, pixi, a 20,000-file folder, a 300-commit history), and keeps the last 3 of each kind. See docs/QA.md. Agent-Team: macos Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- docs/QA.md | 34 +++++++ scripts/qa-build | 212 +++++++++++++++++++++++++++++++++++++++++ scripts/qa-plugins.txt | 8 ++ 3 files changed, 254 insertions(+) create mode 100644 docs/QA.md create mode 100755 scripts/qa-build create mode 100644 scripts/qa-plugins.txt diff --git a/docs/QA.md b/docs/QA.md new file mode 100644 index 000000000..bb4e8ca44 --- /dev/null +++ b/docs/QA.md @@ -0,0 +1,34 @@ +# The QA build + +`scripts/qa-build` keeps a Debug build of `main` with every plugin on a Mac, a seeded QA profile, +and builds of pull requests beside it for before/after. It lives in this repo because it is a +short script over `scripts/zb` and the plugin build, and it has to change with the build; the +pinned store plugins are `scripts/qa-plugins.txt`. + +```sh +scripts/qa-build main # build origin/main if it moved (incremental), install it +scripts/qa-build pr 815 # pull request 815, beside main's +scripts/qa-build run main # or: run pr-815 +scripts/qa-build reseed main # a fresh QA profile and fixtures +scripts/qa-build list +scripts/qa-build watch 300 # `main` every 5 minutes: the trigger after a merge +``` + +Everything is under `$FIZZY_QA_HOME` (default `~/fizzy-qa`); `main` and `pr-` there are +symlinks to the newest build of each. The last 3 builds of each kind are kept. A build is its own +directory (the app, its in-tree plugin dylibs, and a `profile/`), so main's and a PR's never share +state. The script always runs fizzy with `--profile` and builds plugins with `FIZZY_PROFILE`, so +the person's config and plugins are never read or written. + +- **External plugins.** pixi, drive and example-plugin are pinned by tag in `qa-plugins.txt` and + built for each build with `zig build --fork=/sdk`: against that tree's SDK, whatever SDK + their own zon pins. One that no longer builds is reported (and listed in `plugins-failed.txt` + in the build, which makes the next `main` retry it); bump its ref in a PR. +- **Profile.** `recents.zon` lists the fixtures, and `settings.zon` enables every installed store + plugin (a fresh profile leaves them off). Fixtures (`fixtures/`): `notes` (text and markdown), + `pixi` (a `.pixi` document from the pinned pixi checkout), `big` (20,000 files in 200 + folders), `history` (one file, 300 commits). +- **Trigger.** `qa-build main` does nothing when `origin/main` is already built, so run it from + `watch`, a launchd agent (`StartInterval` 300) or the PM's worker after a merge. A second run + while one is building is not guarded; use one trigger. +- **Not covered.** A pixi document's undo history is not seeded; "long history" is a git history. diff --git a/scripts/qa-build b/scripts/qa-build new file mode 100755 index 000000000..a023d7626 --- /dev/null +++ b/scripts/qa-build @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# The QA build: Debug fizzy with every plugin, a seeded QA profile, builds of main and of pull +# requests side by side, all under one directory that is not the person's fizzy. +# +# scripts/qa-build main build origin/main if it moved (incremental), install it +# scripts/qa-build pr build pull request N beside main's, for before/after +# scripts/qa-build run [main|pr-N] start that build on its QA profile (default: main) +# scripts/qa-build reseed [main|pr-N] give a build a fresh QA profile (fixtures regenerated) +# scripts/qa-build list the builds kept +# scripts/qa-build watch [secs] `main` every (default 300): the trigger after a merge +# +# Layout under $FIZZY_QA_HOME (default ~/fizzy-qa): +# repo/ a clone of the origin; trees/ are its worktrees, one per build kind, +# so each keeps its own .zig-cache and the next build is incremental +# plugins// clones of the store plugins pinned in scripts/qa-plugins.txt +# fixtures/ sample content, shared by every profile (text, pixi, big folder, history) +# builds/-/ fizzy + plugins/ (in-tree dylibs) + profile/ (QA profile, store plugins) +# main, pr- symlinks to the newest build of each: the fixed paths to run +# The last 3 builds of main and the last 3 of PR builds are kept. Every run passes --profile, and +# every plugin build sets FIZZY_PROFILE, so nothing here touches the person's config or plugins. +set -euo pipefail + +qa="${FIZZY_QA_HOME:-$HOME/fizzy-qa}" +origin="${FIZZY_QA_ORIGIN:-https://github.com/fizzyedit/fizzy}" +keep=3 +here="$(cd "$(dirname "$0")" && pwd)" +pins="${FIZZY_QA_PINS:-$here/qa-plugins.txt}" +zb="$here/zb" +os_arch="$(uname -m | sed 's/aarch64/arm64/')-$(uname -s | tr A-Z a-z | sed 's/darwin/macos/')" + +die() { echo "qa-build: $*" >&2; exit 1; } +log() { echo "== $*"; } + +ensure_repo() { + mkdir -p "$qa" + if [[ ! -d $qa/repo/.git ]]; then + git clone --quiet "$origin" "$qa/repo" + fi +} + +# tree : the worktree for a build kind, at the commit names. Prints the sha. +tree() { + local name=$1 ref=$2 dir="$qa/repo/trees/$1" + git -C "$qa/repo" fetch --quiet origin "$ref" || return 1 + local sha + sha=$(git -C "$qa/repo" rev-parse FETCH_HEAD) || return 1 + if [[ -d $dir ]]; then + git -C "$dir" checkout --quiet --detach "$sha" || return 1 + else + mkdir -p "$qa/repo/trees" + git -C "$qa/repo" worktree add --quiet --detach "$dir" "$sha" || return 1 + fi + echo "$sha" +} + +# fixtures: sample content the QA profile points at. Made once; `reseed` makes it again. +make_fixtures() { + local f="$qa/fixtures" + rm -rf "$f" + mkdir -p "$f/notes" "$f/big" "$f/history" + printf '# Notes\n\nA short text file.\n\n- one\n- two\n' > "$f/notes/hello.md" + printf 'const std = @import("std");\n\npub fn main() void {\n std.debug.print("qa\\n", .{});\n}\n' > "$f/notes/hello.zig" + # A large folder: 200 directories of 100 small files. + local d i + for d in $(seq 1 200); do + mkdir -p "$f/big/dir-$d" + for i in $(seq 1 100); do echo "file $d/$i" > "$f/big/dir-$d/file-$i.txt"; done + done + # A document with a long history: one file, 300 commits, in a repository of its own. + git -C "$f/history" init --quiet + git -C "$f/history" config user.email qa@example.invalid + git -C "$f/history" config user.name qa + for i in $(seq 1 300); do + echo "line $i: $(printf '%0.s-' $(seq 1 $((i % 40 + 10))))" >> "$f/history/log.txt" + git -C "$f/history" add log.txt + git -C "$f/history" commit --quiet -m "edit $i" + done + # A pixi document, from the pinned pixi checkout's own assets. + local pixi="$qa/plugins/pixi/assets/src/misc.pixi" + if [[ -f $pixi ]]; then mkdir -p "$f/pixi" && cp "$pixi" "$f/pixi/"; fi +} + +# seed_profile : a fresh QA profile whose recents are the fixtures. +seed_profile() { + local p=$1 f="$qa/fixtures" + rm -rf "$p" + mkdir -p "$p/plugins" + [[ -d $f ]] || make_fixtures + local folders="" opened="" now d + now=$(date +%s) + for d in history big notes pixi; do + [[ -d $f/$d ]] || continue + folders+="\"$f/$d\", " + opened+="$now, " + done + printf '.{ .folders = .{ %s}, .folders_opened = .{ %s} }\n' "$folders" "$opened" > "$p/recents.zon" +} + +# plugin_sources: clone or update each pinned store plugin. +plugin_sources() { + local id repo ref + while read -r id repo ref; do + [[ -z ${id:-} || $id == \#* ]] && continue + local dir="$qa/plugins/$id" + if [[ ! -d $dir/.git ]]; then + mkdir -p "$qa/plugins" + git clone --quiet "https://github.com/$repo" "$dir" + fi + git -C "$dir" fetch --quiet --tags origin + git -C "$dir" checkout --quiet --detach "$ref" 2>/dev/null || git -C "$dir" checkout --quiet --detach "origin/$ref" + done < "$pins" +} + +# build_plugins : each pinned plugin, Debug, against the tree's SDK, installed +# into the build's profile. A plugin that fails is reported and left out. +build_plugins() { + local tree=$1 profile=$2 id repo ref failed=() + while read -r id repo ref; do + [[ -z ${id:-} || $id == \#* ]] && continue + log "plugin $id @ $ref" + if ! (cd "$qa/plugins/$id" && FIZZY_PROFILE="$profile" "$zb" build --fork="$tree/sdk" install 2>&1 | tail -n 15); then + failed+=("$id") + fi + done < "$pins" + if ((${#failed[@]})); then + echo "qa-build: plugins that did not build against this SDK: ${failed[*]}" >&2 + printf '%s\n' "${failed[@]}" > "$profile/../plugins-failed.txt" + fi +} + +# enable_plugins : a store plugin installed in a fresh profile stays off until enabled. +enable_plugins() { + local p=$1 d id body="" + for d in "$p"/plugins/*/; do + [[ -d $d ]] || continue + id=$(basename "$d") + body+=".@\"$id\" = .{ .enabled = true }, " + done + printf '.{ .plugins = .{ %s} }\n' "$body" > "$p/settings.zon" +} + +# build : build and install; no-op when that commit is already installed. +build() { + local kind=$1 name=$2 ref=$3 + ensure_repo + local sha short out + sha=$(tree "$kind" "$ref") + short=${sha:0:9} + out="$qa/builds/$name-$short" + if [[ -x $out/fizzy && ! -f $out/plugins-failed.txt ]]; then + log "$name is already at $short" + link "$name" "$out" + return + fi + local t="$qa/repo/trees/$kind" + log "fizzy $name @ $short (Debug)" + (cd "$t" && "$zb" build -Doptimize=Debug) + rm -rf "$out" + mkdir -p "$out" + cp -R "$t/zig-out/$os_arch/." "$out/" + plugin_sources + seed_profile "$out/profile" + build_plugins "$t" "$out/profile" + enable_plugins "$out/profile" + link "$name" "$out" + prune "$name" + log "$name ready: $qa/$name -> $out" +} + +link() { ln -sfn "$2" "$qa/$1"; } + +prune() { + local kind=${1%%-*} + local old + # shellcheck disable=SC2012 + ls -1dt "$qa"/builds/"$kind"-* 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do rm -rf "$old"; done +} + +resolve() { + local b=${1:-main} + [[ -L $qa/$b ]] || die "no build named $b (qa-build list)" + readlink "$qa/$b" +} + +case "${1:-}" in +main) build main main main ;; +pr) + [[ ${2:-} =~ ^[0-9]+$ ]] || die "usage: qa-build pr " + build pr "pr-$2" "refs/pull/$2/head" + ;; +run) + b=$(resolve "${2:-}") + exec "$b/fizzy" --profile "$b/profile" + ;; +reseed) + b=$(resolve "${2:-}") + make_fixtures + seed_profile "$b/profile.new" + # Keep the store plugins installed in the old profile. + cp -R "$b/profile/plugins/." "$b/profile.new/plugins/" 2>/dev/null || true + enable_plugins "$b/profile.new" + rm -rf "$b/profile" && mv "$b/profile.new" "$b/profile" + ;; +list) ls -1dt "$qa"/builds/* 2>/dev/null || echo "no builds" ;; +watch) + while true; do + "$0" main || echo "qa-build: main failed; retrying" >&2 + sleep "${2:-300}" + done + ;; +*) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 2 ;; +esac diff --git a/scripts/qa-plugins.txt b/scripts/qa-plugins.txt new file mode 100644 index 000000000..122804541 --- /dev/null +++ b/scripts/qa-plugins.txt @@ -0,0 +1,8 @@ +# The store plugins scripts/qa-build adds to every QA build: . +# A ref is a tag or a branch of github.com/. Each is built against the SDK of the fizzy +# tree being built (`zig build --fork=/sdk`), whatever SDK its own build.zig.zon pins, so +# a ref that no longer builds against main's SDK shows as a failed plugin in the QA build's +# report; bump the ref here (or fix the plugin) in a PR. +pixi fizzyedit/pixi v0.1.51 +drive fizzyedit/drive v0.1.20 +example-plugin fizzyedit/example-plugin main