Skip to content

refactor(app): migrate App.vue to Pinia (47 commits — App 2211→72 code lines, + store tests/debt) #1511

refactor(app): migrate App.vue to Pinia (47 commits — App 2211→72 code lines, + store tests/debt)

refactor(app): migrate App.vue to Pinia (47 commits — App 2211→72 code lines, + store tests/debt) #1511

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# CI only reads code — no releases or package pushes.
permissions:
contents: read
jobs:
# ── Job 1: All linters ──────────────────────────────────────────────────
lint:
runs-on: ubuntu-latest
# Extra scopes are needed by EnricoMi/publish-unit-test-result-action so
# it can post the sticky comment + checks summary on pull requests. The
# workflow-level default is contents:read; per-job permissions replace
# rather than merge, so contents:read is re-listed below.
permissions:
contents: read
checks: write
pull-requests: write
issues: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# mise provisions this job's slice of the toolchain from mise.toml —
# go, node, task, golangci-lint (built from source with this Go so it
# accepts the project's go directive — a prebuilt binary on an older Go
# would reject it), shfmt, shellcheck, ruff, jq, go-junit-report — and
# loads mise.toml [env] (SPECTRAL_VERSION, …, GOTOOLCHAIN=local) into
# $GITHUB_ENV regardless of install_args. Replaces setup-go + the
# tool-versions.env grep + every per-tool `go install`/pipx step.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: >-
go node task ruff shfmt shellcheck jq
go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint
go:github.com/jstemmer/go-junit-report/v2
# Frontend — set up Node and build frontend/dist before Go linting so the
# //go:embed all:frontend/dist directive in assets.go passes type-checking,
# AND so the bundle-size budget step below can measure the real artifacts.
- name: Prepare frontend/dist (real Vite build)
uses: ./.github/actions/prepare-frontend-dist
with:
real-assets: "true"
# Bundle-size budget. Catches accidental regressions (e.g. someone
# imports a 200KB library by mistake). Two budgets:
# - Initial chunk (index-*.{js,css}): what every page-load pays.
# The four view components are lazy-loaded via
# defineAsyncComponent so each becomes a separate chunk that
# isn't counted here — only the masthead/modal/router-shell
# code lands in index-*.
# - Total assets/: catches the case where lazy-splitting masks a
# real regression by smearing it across chunks.
# Bump deliberately when a real feature needs the room.
# Bundle-size budget. Logic lives in scripts/ci/check-bundle-size.sh
# so the lefthook pre-push hook can reuse the same assertion —
# contributors fail locally before push instead of finding out
# from a red CI badge. Per-chunk + total caps live in the script
# as defaults; override via env var if a one-off needs room.
- name: Enforce bundle-size budget
run: bash scripts/ci/check-bundle-size.sh
- name: Lint Go (default build tags)
run: golangci-lint run ./...
- name: Lint Go (serveronly build tag)
run: golangci-lint run --build-tags serveronly ./...
# Frontend linters — deps already installed above
- name: Lint JavaScript/Vue (ESLint)
run: cd frontend && npm run lint:js
- name: Lint CSS (Stylelint)
run: cd frontend && npm run lint:css
- name: Lint HTML (HTMLHint)
run: cd frontend && npm run lint:html
# Python — ruff lint + format-check. ruff comes from mise (pinned in
# mise.toml [tools]); no pipx install needed.
- name: Lint Python (ruff)
run: task lint-py
# Shell scripts in scripts/ — shellcheck for correctness, shfmt for
# style. Both come from mise (pinned in mise.toml), so no install step.
- name: Lint shell scripts (shellcheck + shfmt)
run: task lint-shell
# Dockerfile
- name: Lint Dockerfile (Hadolint)
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: Dockerfile.build
# OpenAPI spec — Spectral with the spectral:oas ruleset + project
# overrides in .spectral.yaml. SPECTRAL_VERSION comes from mise.toml
# [env] (loaded into $GITHUB_ENV by mise-action).
- name: Lint OpenAPI (Spectral)
run: task lint-openapi
# Spell-check code, docs, and identifiers. The action ships the
# typos binary so the runner doesn't need a separate install
# step; config lives in _typos.toml at the repo root.
- name: Spell-check (typos)
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # v1.47.2
# Markdown lint — mirrors `task lint-md`. Config in
# .markdownlint-cli2.yaml at the repo root; npx pulls the
# version on demand so no global install is required.
- name: Lint Markdown (markdownlint-cli2)
run: npx --yes markdownlint-cli2
# GitHub Actions workflow lint — catches deprecated inputs,
# syntax errors, and shellcheck issues in `run:` blocks. The
# action ships the actionlint binary so no install step.
- name: Lint workflows (actionlint)
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
# Enforce the project's SHA-pinning policy for third-party
# GitHub Actions. A tag-pinned action could be silently
# re-pointed to a malicious commit; see CONTRIBUTING.md
# → "Pinning GitHub Actions".
- name: Enforce SHA-pinned actions
run: bash scripts/ci/check-action-pins.sh
# Go unit tests (merge logic, inference helpers, screenshotType
# classification). Parser golden-file tests are skipped by -short.
# Tee through go-junit-report so the XML is always produced — the
# final pass/fail decision is delegated to the EnricoMi step below
# via `fail_on: test failures` so a fork PR without comment perms
# still surfaces failures via the workflow conclusion.
# Test-skip inventory gate. Diffs every `t.Skip*` line under
# pkg/ against `scripts/ci/test-skips-allow.txt`. A new skip
# without an allow-list entry fails CI — flake-suppression
# skips are forbidden; the allow-list is for documented
# environment gates (OS-conditional probe tests, `-short`-mode
# tesseract integration). Mirrors the lefthook pre-push
# `test-skips` hook so the gate fires both locally and in CI.
- name: Inventory test skips
run: bash scripts/ci/check-test-skips.sh
- name: Run Go unit tests
id: gotest
# continue-on-error so BOTH test steps run and the JUnit XML always
# uploads + EnricoMi always posts the "Unit test results" check. The
# real pass/fail is enforced by the "Fail the job on any unit-test
# failure" gate at the end of this job (reads `.outcome`) — so a
# failure, including a -race-only one, reds the workflow run, not just
# the check-run.
continue-on-error: true
run: |
set -o pipefail
go test -race -short -v ./... 2>&1 \
| tee go-test.log \
| go-junit-report -set-exit-code > go-junit.xml
# Frontend unit tests (Vitest) — pure-helper coverage for the
# extracted match-helpers.ts module. `--reporter=default` keeps the
# log readable in the Actions run; `--reporter=junit` adds the XML
# consumed by the EnricoMi step. The `--outputFile.junit=` form
# scopes the path to the junit reporter only, so the default
# reporter still writes to stdout.
- name: Run frontend unit tests
id: fetest
continue-on-error: true
run: |
cd frontend
npx vitest run \
--reporter=default \
--reporter=junit \
--outputFile.junit=../frontend-junit.xml
# Upload the JUnit XML so the coverage-comment job can fold its
# numbers + per-failure details into the single combined PR
# comment. `if: always()` so the artifact still uploads when a
# test step exited non-zero (which is the normal path on a
# failing test — `continue-on-error: true` makes the step succeed,
# but EnricoMi below is what flips the workflow to red).
- name: Upload unit-test JUnit XML
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: unit-test-results
path: |
go-junit.xml
frontend-junit.xml
if-no-files-found: warn
retention-days: 7
# EnricoMi posts the "Unit test results" check (red via `fail_on:
# test failures` when the JUnit XML carries a failure) — that's the
# per-PR/commit status + the detailed breakdown. It does NOT fail the
# workflow run itself, so the explicit `.outcome` gate at the end of
# this job is what reds CI. Its own commenter is OFF — the
# coverage-comment job renders one combined sticky comment.
- name: Publish unit-test results check
if: always()
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
with:
check_name: Unit test results
comment_mode: off
files: |
go-junit.xml
frontend-junit.xml
fail_on: test failures
# TypeScript type-check — confirms api.ts agrees with the
# OpenAPI-generated types in api.gen.d.ts.
- name: TypeScript type-check
run: cd frontend && npm run typecheck
# Verify the committed types match the current OpenAPI spec.
# Catches "you forgot to run `task gen-types` after editing
# api/openapi.yaml" before it lands on main.
- name: Verify generated types are in sync with OpenAPI spec
run: |
task gen-types
if ! git diff --quiet frontend/src/api.gen.d.ts; then
echo "::error::frontend/src/api.gen.d.ts is out of sync with api/openapi.yaml — run 'task gen-types' and commit"
git diff frontend/src/api.gen.d.ts | head -50
exit 1
fi
# Hard-fail CI on any unit-test failure. The two test steps are
# continue-on-error (so JUnit always uploads + EnricoMi always posts its
# check), which means their failure alone only reds the "Unit test
# results" check — the workflow run stays green. Gate on their real
# `.outcome` here so a failing test, including one that only fails under
# `-race`, turns this job (and the CI run) red directly. Last step in the
# job so every other check still runs first.
- name: Fail the job on any unit-test failure
if: always() && (steps.gotest.outcome == 'failure' || steps.fetest.outcome == 'failure')
env:
GO_OUTCOME: ${{ steps.gotest.outcome }}
FE_OUTCOME: ${{ steps.fetest.outcome }}
run: |
echo "::error::Unit tests failed (Go=${GO_OUTCOME}, frontend=${FE_OUTCOME}). See the 'Unit test results' check and the test-run step logs."
exit 1
# ── Job 2: Linux/amd64 Wails app ────────────────────────────────────────
build-linux:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
# Docker does the heavy build; the host only needs task + jq (the
# build-* tasks compute BUILD_VERSION via jq), so install just those.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: task jq
- name: Build Linux/amd64 Wails app
run: task build-linux
# ── Job 3: Windows/amd64 Wails app ──────────────────────────────────────
build-windows:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: task jq
- name: Build Windows/amd64 Wails app
run: task build-windows
# ── Job 4: Server binaries + container image ─────────────────────────────
build-server:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: task jq
- name: Build all server binaries (Linux, Windows, macOS arm64+amd64)
run: task build-server-all
- name: Build Linux server container image (with Tesseract)
run: task build-server-container
# ── Job 5: macOS Wails build (requires Apple SDK / Xcode CLT) ────────────
build-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# Go + Node + Wails CLI (+ task, jq) from mise.toml's pins.
- uses: ./.github/actions/wails-build-env
- name: Build macOS universal Wails app
run: task build-mac
# ── Job 6: Vulnerability scan ────────────────────────────────────────────
# Mirrors `task trivy` but runs in CI and uploads results to the GitHub
# Security tab (Alerts → Code scanning) as well as failing the build.
trivy:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln
severity: HIGH,CRITICAL
exit-code: '1'
format: sarif
output: trivy.sarif
- name: Upload Trivy results to GitHub Security tab
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: trivy.sarif
# ── Job 7: Go-specific vulnerability scan ────────────────────────────────
# govulncheck is call-graph-aware: it only flags CVEs in code paths Recall
# actually invokes. Complements Trivy (which scans all module versions
# regardless of reachability) and typically produces fewer false positives.
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go + govulncheck from mise.toml (govulncheck built with this Go).
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go go:golang.org/x/vuln/cmd/govulncheck
- name: Scan Go modules (default build tag)
run: govulncheck ./...
- name: Scan Go modules (serveronly build tag)
run: govulncheck -tags serveronly ./...
# gosec is a Go-idiom SAST: catches issues CodeQL and govulncheck don't
# (Slowloris timeouts, file-mode bugs, weak crypto, taint-tracked path/
# command/log injection). Mirrors `task lint-gosec`. Both build tags are
# swept so the Wails and serveronly code paths both get covered.
gosec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go + gosec from mise.toml. mise's go: backend compiles gosec with the
# pinned Go 1.26.4 — the same reason the old job avoided securego/gosec's
# docker action (it bundles a stale Go that can't load a go 1.26 module
# under GOTOOLCHAIN=local). Version pinned in mise.toml (v2.27.1).
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go go:github.com/securego/gosec/v2/cmd/gosec
# gosec only needs Go source to compile, not real frontend
# assets — stub the embed directory rather than running a real
# Vite build (saves ~30s per CI run).
- name: Prepare frontend/dist (stub for //go:embed)
uses: ./.github/actions/prepare-frontend-dist
with:
real-assets: "false"
- name: Run gosec (default build tag)
run: gosec -exclude-dir=frontend ./...
- name: Run gosec (serveronly build tag)
run: gosec -exclude-dir=frontend -tags=serveronly ./...
# JS/TS SAST in CI is handled by CodeQL's javascript-typescript
# language matrix in .github/workflows/codeql.yml. Semgrep runs
# locally only — via `task lint-semgrep` and the pre-push lefthook
# hook — so contributors get fast feedback before push without
# duplicating coverage with CodeQL.
# ── Job 8: Cyclomatic complexity (REPORT ONLY) ───────────────────────────
# gocyclo (Go) + ESLint's `complexity` rule (frontend) at threshold 10.
# `continue-on-error: true` keeps the job NON-BLOCKING — a high number
# never fails the build, it just shows up in the job log. We watch the
# trend across PRs to know when to refactor; tightening into a hard
# gate is its own deliberate decision, not a side-effect of this job.
# Threshold + tooling pins both live in scripts/ci/check-complexity.sh +
# mise.toml (gocyclo).
complexity:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go (gocyclo) + node (eslint pass) from mise.toml.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go node go:github.com/fzipp/gocyclo/cmd/gocyclo
# gocyclo doesn't need real frontend assets — Go source only.
- name: Prepare frontend/dist (stub for //go:embed)
uses: ./.github/actions/prepare-frontend-dist
with:
real-assets: "false"
- name: Install frontend deps (for the eslint pass)
run: npm --prefix frontend ci
- name: Run complexity sweep (Go + frontend, threshold 10)
run: bash scripts/ci/check-complexity.sh
# Diff the current Go scores against the checked-in top-20
# baseline. Same threshold, same exclusions; emits a summary
# line + per-jump/newcomer record. continue-on-error keeps the
# job non-blocking; a future PR-comment step can hook into
# this output once the baseline shape stabilises.
- name: Diff against baseline (report-only)
run: bash scripts/ci/check-complexity-baseline.sh
# ── Job 9: Dead code analysis ────────────────────────────────────────────
# deadcode does whole-program call-graph analysis for Go (serveronly variant
# only — the Wails variant registers App methods via reflection so deadcode
# would report false positives; golangci-lint `unused` covers that variant).
# knip scans the frontend for unused TypeScript exports and stale deps.
dead-code:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go (+ deadcode) and node (+ knip) from mise.toml.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go node go:golang.org/x/tools/cmd/deadcode
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Build frontend (required for Go embed typecheck)
run: cd frontend && npm run build
- name: Dead Go code (serveronly build tag)
# Delegates to scripts/ci/deadcode-check.sh; the allow-list of
# intentional unreachables lives in scripts/ci/deadcode-allow.txt
# and is shared with Make + lefthook so all three call sites
# stay in sync.
run: bash scripts/ci/deadcode-check.sh
- name: Dead TypeScript code (knip)
run: cd frontend && npm run dead:ts
# ── Job 10: Frontend coverage report ─────────────────────────────────────
# Informational only — no thresholds enforced. The HTML report and lcov
# file are uploaded as a workflow artifact so they can be downloaded from
# the Actions run page for any push or PR.
coverage-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: node
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Generate coverage report
run: cd frontend && npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: frontend-coverage
path: frontend/coverage/
retention-days: 30
# ── Job 11: Go coverage report ───────────────────────────────────────────
# Informational only — no thresholds enforced. The HTML report and func
# summary are uploaded as a workflow artifact so they can be downloaded
# from the Actions run page for any push or PR.
coverage-go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go + gocover-cobertura (Cobertura XML converter) from mise.toml.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go task go:github.com/boumenot/gocover-cobertura
# Frontend build is required because assets.go uses
# //go:embed all:frontend/dist — compiling the root `recall`
# package (via `go test ./...`) fails if dist/ is missing.
- name: Prepare frontend/dist (real Vite build)
uses: ./.github/actions/prepare-frontend-dist
with:
real-assets: "true"
- name: Generate Go coverage report
run: task cover-go
# gocover-cobertura converts Go's native coverprofile into the
# Cobertura XML format that irongut/CodeCoverageSummary parses.
# Bundled into the artifact so the coverage-comment job downloads
# both formats in a single fetch.
- name: Convert coverage profile to Cobertura XML
run: gocover-cobertura < coverage/go/coverage.out > coverage/go/cobertura.xml
- name: Upload coverage report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: go-coverage
path: coverage/go/
retention-days: 30
# ── Job 12: OpenAPI ↔ implementation drift check ─────────────────────────
# Spectral checks the spec is well-formed; schemathesis checks the spec
# matches the running server. Fuzzes spec-derived requests against a
# freshly-built serveronly binary and verifies every response conforms to
# the declared schema. Catches the "I added a route but forgot to update
# api/openapi.yaml" class of bug that Spectral can't see.
schemathesis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# go (build serveronly) + schemathesis (pipx) from mise.toml; also loads
# TESSERACT_VERSION + SCHEMATHESIS_VERSION from [env] into $GITHUB_ENV.
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
with:
install_args: go task pipx:schemathesis
- name: Prepare frontend/dist (real Vite build)
uses: ./.github/actions/prepare-frontend-dist
with:
real-assets: "true"
- name: Install Tesseract (no-op for API surface but Startup checks for it)
# Assert installed major.minor matches TESSERACT_VERSION from mise.toml
# [env] (loaded into $GITHUB_ENV by mise-action) so OCR drift can't
# silently land between what dev baselined the goldens against and what
# CI runs. Loud-fail when Ubuntu bumps the package version — that's the
# signal to re-baseline goldens + bump the pin.
run: |
sudo apt-get update -y
sudo apt-get install -y --no-install-recommends tesseract-ocr
installed=$(tesseract --version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
expected_mm=$(printf '%s' "$TESSERACT_VERSION" | cut -d. -f1-2)
installed_mm=$(printf '%s' "$installed" | cut -d. -f1-2)
if [ "$installed_mm" != "$expected_mm" ]; then
echo "::error::Tesseract major.minor mismatch: installed=$installed_mm expected=$expected_mm (TESSERACT_VERSION=$TESSERACT_VERSION in mise.toml). Bump the pin + re-baseline testdata/*.golden.json."
exit 1
fi
echo "Tesseract $installed matches pinned major.minor $expected_mm"
- name: Build + boot + fuzz + teardown via shared script
# scripts/ci/check-api-drift.sh handles the build-serveronly →
# boot-on-isolated-tempdir → schemathesis-run → teardown
# sequence. Same script lefthook's pre-push schemathesis hook
# invokes so the local + CI signals stay 1:1. The script
# short-circuits with an actionable error if schemathesis or
# frontend/dist are missing.
run: SKIP_FRONTEND_BUILD=1 bash scripts/ci/check-api-drift.sh