Skip to content

feat: copy management — react-i18next adopted, Settings migrated (goal 0032) #55

feat: copy management — react-i18next adopted, Settings migrated (goal 0032)

feat: copy management — react-i18next adopted, Settings migrated (goal 0032) #55

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Least-privilege default (goal 0024): every job gets read-only contents
# access unless it explicitly needs more (the `changes` job overrides for
# pull-requests: read; release.yml's attestation job separately overrides
# for id-token/attestations write, unaffected by this).
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
# Conditional, per GitHub's own docs pattern: a superseded PR run
# SHOULD die (per-PR synthetic ref), but main's post-merge runs must
# each complete — with `true` here, two merges landing close together
# cancelled the earlier merge commit's own verification run, leaving
# that main SHA's CI status permanently incomplete (observed live
# 2026-08-12: merges #1/#10 showed cancelled; the mirror image of
# ADR-0034's bisect-blind-spot concern).
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# goal 0024 / ADR-0034's un-deferred path-filtering: skip the heavy
# matrix on docs-only PRs without ever making a required check hang at
# "Expected" (ADR-0034's originally-cited footgun). The fix is the
# job-level `if:` pattern, not workflow-level `on.pull_request.paths` --
# a skipped job still REPORTS a (green, skip) status for the check, so
# a required check never hangs; only a `paths`-filtered workflow that
# doesn't run AT ALL would do that.
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read
outputs:
code: ${{ steps.decide.outputs.code }}
steps:
# dorny/paths-filter fetches the changed-file list via the GitHub
# REST API for pull_request events -- no actions/checkout needed.
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
if: github.event_name == 'pull_request'
with:
# some-with-excludes (not the 'some' default): '**' matches
# every file, so with the default quantifier the negated rules
# would be silently ignored and `code` would always be true.
# some-with-excludes requires a changed file to match a
# positive rule (it does, via '**') AND match none of the
# negated ones -- i.e. true only if some changed file is
# outside docs/**, *.md (any depth), .claude/**, and LICENSE.
predicate-quantifier: 'some-with-excludes'
filters: |
code:
- '**'
- '!docs/**'
- '!**/*.md'
- '!.claude/**'
- '!LICENSE'
- name: Decide code output
id: decide
run: |
# paths-filter needs a diff base to compare against, which only
# exists for pull_request events. A `push` to main (the
# ADR-0034 catch-up push, or the ruleset's own PR-merge commit)
# has no PR base to diff -- path filtering is skipped entirely
# and `code` defaults true, so a push to main always runs the
# full suite, never silently skips it.
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "code=${{ steps.filter.outputs.code }}" >> "$GITHUB_OUTPUT"
else
echo "code=true" >> "$GITHUB_OUTPUT"
fi
# Mirrors lefthook.yml's own file-loc-limit job -- same script, so the
# two can't drift. No checkout-heavy setup needed (just git ls-files +
# wc), so this doesn't depend on the frontend job below. Deliberately
# NOT gated on `changes.outputs.code` -- fast, and docs CAN violate it
# (a doc file itself can cross the line limit).
file-loc-limit:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: ./scripts/check-loc.sh
# Mirrors lefthook.yml's own rules-frontmatter job -- same script.
# Catches a real bug class: a .claude/rules/*.md with an invalid
# frontmatter key (e.g. a `globs:` typo instead of `paths:`) silently
# never scopes the way it looks like it should, with no error anywhere
# else to surface it. Deliberately NOT gated on `changes.outputs.code`
# -- fast, and a docs-only PR is exactly the kind of change that could
# touch .claude/rules/*.md.
rules-frontmatter:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: ./scripts/check-rules-frontmatter.sh
# Enforces the root-layout rule (exactly one root Go file, main.go;
# services live under internal/services/<ctx>svc) via ls-lint
# (adopted, not hand-rolled -- unlike file-loc-limit, a real commodity
# tool covers this: see .ls-lint.yml's own header comment for the
# config shape verified empirically before relying on it). go install
# matches how this repo already sets up gopls/wails3 -- no
# npm/Homebrew step. Deliberately NOT gated on `changes.outputs.code`
# -- fast, and a stray root file can land in a docs-only PR too.
root-file-naming:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
cache: true
- run: go install github.com/loeffel-io/ls-lint/v2/cmd/ls_lint@v2.3.1
- run: ls_lint
# Lints and builds frontend/dist once; Go jobs below need frontend/dist
# present (main.go embeds it via //go:embed all:frontend/dist), so they
# download it instead of rebuilding it redundantly per job/OS.
frontend:
needs: changes
if: success() && needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run lint
working-directory: frontend
- run: npm run boundaries
working-directory: frontend
- run: npm run test
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: frontend/dist
retention-days: 1
lint-go:
needs: [changes, frontend]
if: success() && needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Same reason as build-go's ubuntu-latest entry: without this,
# golangci-lint's own package-loading step needs cgo + X11 dev
# headers for internal/adapters/hotkey's desktop-tagged file.
# Real failure the first time this ran in actual CI, not caught
# locally beforehand -- golangci-lint-action has no CGO_ENABLED
# input, hence the job-level env instead.
CGO_ENABLED: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
cache: true
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.12
args: --build-tags=server
build-go:
needs: [changes, frontend]
if: success() && needs.changes.outputs.code == 'true'
strategy:
# A 2-platform build-verification matrix must report both legs
# independently -- one platform failing shouldn't cancel the other
# before it's even reported (goal 0024).
fail-fast: false
matrix:
include:
# macOS: desktop build (default tags) — primary target, per SPEC.md.
# Needs Xcode CLI tools for the hotkey package's cgo/Objective-C
# backend; GitHub's macos-latest runners ship these preinstalled.
- os: macos-latest
tags: ''
cgo: '1'
timeout-minutes: 15
# Linux: server-mode build only. CGO_ENABLED=0 is required, not
# optional -- Wails3's own internal/operatingsystem and
# internal/assetserver/webview packages are cgo-gated onto
# GTK4/webkitgtk-6.0 pkg-config packages this runner doesn't have,
# and pull them in regardless of the `server` build tag unless
# cgo itself is disabled. Confirmed by actually building natively
# in a linux/amd64 container, not assumed: `go build -tags server
# .` fails on missing gtk4/webkitgtk-6.0 pkg-config with the
# default CGO_ENABLED=1, and only succeeds with CGO_ENABLED=0 --
# matching build/docker/Dockerfile.server's own default for
# exactly this reason.
- os: ubuntu-latest
tags: server
cgo: '0'
timeout-minutes: 10
runs-on: ${{ matrix.os }}
timeout-minutes: ${{ matrix.timeout-minutes }}
env:
CGO_ENABLED: ${{ matrix.cgo }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
cache: true
# Scoped to the root package + internal/, not `./...`: build/ios,
# build/android etc. are gomobile-toolchain scaffold with no main()
# outside that toolchain, and frontend/node_modules happens to bundle
# unrelated vendored Go source neither is part of Mill's own build.
- run: go build ${{ matrix.tags && format('-tags {0}', matrix.tags) || '' }} .
- run: go vet ${{ matrix.tags && format('-tags {0}', matrix.tags) || '' }} . ./internal/...
test-go:
needs: [changes, frontend]
if: success() && needs.changes.outputs.code == 'true'
# macos-latest, not ubuntu-latest -- resolved a real contradiction the
# dormant-pipeline period shipped unverified (caught by the first
# catch-up run after ADR-0034): on ubuntu, CGO_ENABLED=0 is
# load-bearing (Wails3's GTK-gated packages, see build-go's server
# entry) but `go test -race` REQUIRES cgo -- mutually exclusive on
# that runner for the root package. macOS is the platform Mill
# actually ships for (release is macOS-only, SPEC §1.3), cgo works
# there by default, and running WITHOUT the server tag makes this job
# the exact mirror of lefthook's local go-test -- which also means
# desktop-tagged code (hotkey_desktop.go etc.) finally COMPILES in CI,
# closing part of SPEC §9.5's named "CI never compiles desktop build
# tags" debt. Server-tagged code still compiles in CI via build-go's
# ubuntu entry and runs via the e2e job's server binary.
runs-on: macos-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
cache: true
# The root package (`.`) is included alongside ./internal/... --
# previously excluded, leaving the root-level service tests running
# in neither CI nor Lefthook. Needs frontend/dist: main.go's
# //go:embed all:frontend/dist makes the root package fail to
# compile without it, even for tests that never touch the embedded
# assets themselves.
- run: go test . ./internal/... -race -cover
e2e:
needs: [changes, frontend]
if: success() && needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
# fail-fast: false -- real incident, not a hypothetical (PR 11,
# run 31557343422, 2026-08-12): shard 3 failed on a real e2e
# regression, and GitHub Actions' own fail-fast DEFAULT (true when
# unset) immediately cancelled shards 1 and 2 mid-run rather than
# letting them finish -- both were killed ~2.5 minutes into a
# passing-so-far run, so their own genuine pass/fail signal was
# simply never collected ("shard-2-cancelled-while-green": it
# wasn't red, it never got the chance to report). A 3-way sharded
# suite needs every shard's own verdict to know what's actually
# broken, not just "at least one shard is red" -- the same
# reasoning build-go's own 2-platform matrix comment already
# states for its own fail-fast: false, applied here too.
fail-fast: false
matrix:
shardIndex: [1, 2, 3]
shardTotal: [3]
env:
# Required for the same reason as build-go's ubuntu-latest entry:
# playwright.config.ts's webServer builds the real server-mode
# binary inline, which needs CGO disabled on Linux to avoid pulling
# in Wails3's GTK-gated desktop code.
CGO_ENABLED: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npx playwright install --with-deps chromium
working-directory: frontend
# 3-shard matrix + playwright.config.ts's workers: CI ? 1 : 4 (goal
# 0024): the prior single-job, workers:2-in-CI shape was the actual
# cause of a 14-failure batch traced to cross-worker contention on
# this runner's CPU/IO, not real regressions -- splitting into
# per-shard jobs (real process isolation) with one worker each
# removes the contention instead of just tuning the number down.
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
working-directory: frontend
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: frontend/playwright-report
retention-days: 7
govulncheck:
# macos-latest, not ubuntu-latest: govulncheck-action has no way to pass
# -tags, and the default (desktop) build tags only compile cleanly
# without extra system deps on macOS -- see build-go's ubuntu-latest
# comment for why that's not true on Linux. macOS is also Mill's
# primary target per SPEC.md, so this is the more representative scan
# anyway, not a workaround-of-convenience.
runs-on: macos-latest
timeout-minutes: 15
# Deliberately NOT gated on `changes.outputs.code` (goal 0024's
# explicit exception) -- advisory-only and cheap enough that skipping
# it on docs-only PRs isn't worth a second conditional to reason
# about; it still depends on `frontend`, so it naturally no-ops
# (skips) whenever frontend itself skips.
needs: frontend
continue-on-error: true # advisory only -- golang/govulncheck-action is
# still self-described experimental (per ADR-0002's research); findings
# are worth seeing, not worth blocking a merge on yet.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
# Two separate steps, not one `go-package: ./internal/... .` -- the
# action passes go-package through a quoted shell variable, so a
# space-separated string becomes ONE malformed pattern
# ("no packages matched the provided patterns"), not two. Real
# failure caught on the first actual CI run, not assumed.
- uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
with:
go-version-input: '1.25'
go-package: ./internal/...
repo-checkout: false # already checked out above; the action's
# own default checkout would wipe the frontend-dist artifact
# just downloaded, which the root package needs (//go:embed).
- uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
with:
go-version-input: '1.25'
go-package: .
repo-checkout: false
# New, goal 0024: flags a newly-introduced vulnerable/malicious/
# license-incompatible dependency directly on the PR that adds it
# (go.sum/package-lock.json diff), rather than only after the fact via
# govulncheck's advisory scan of what's already merged.
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# goal 0028: a copyleft dependency can't enter via PR unnoticed -- Apache-2.0 (Mill's own LICENSE) is incompatible with GPL/AGPL's copyleft terms.
deny-licenses: GPL-2.0-only, GPL-2.0-or-later, GPL-3.0-only, GPL-3.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later
# The future single required check (goal 0024/ADR-0034): decouples the
# branch ruleset from job-name churn -- the ruleset names only
# `ci-gate`, so adding/renaming/splitting a job upstream never requires
# a matching ruleset edit. Runs unconditionally (`always()`) so it can
# itself observe every other job's result, including ones skipped by
# the `changes` gate above -- a skipped job counts as a pass (that's
# the whole point of gating on docs-only changes: nothing required
# should ever block on a job that correctly chose not to run).
ci-gate:
name: CI gate
needs:
- changes
- file-loc-limit
- rules-frontmatter
- root-file-naming
- frontend
- lint-go
- build-go
- test-go
- e2e
- govulncheck
- dependency-review
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify no required job failed or was cancelled
env:
NEEDS_CONTEXT: ${{ toJSON(needs) }}
run: |
failed=$(echo "$NEEDS_CONTEXT" | jq -r \
'to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key')
if [ -n "$failed" ]; then
echo "::error::Required job(s) failed or were cancelled: $failed"
exit 1
fi
echo "All required jobs passed or were correctly skipped."