feat: runtime plugins can register drag tools with style pickers and … #1358
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} | |
| # Never cancel: PR runs on merge refs were being phantom-cancelled | |
| # mid-run with no superseding push visible (goal 0227 — five kills | |
| # on one PR, two on another, both starved of any completed run), | |
| # consistent with merge-ref recomputation spawning a suite that | |
| # enters this group and kills the in-flight attempt. `false` | |
| # everywhere both fixes the starvation and is the controlled test | |
| # of that mechanism; the cost is an occasionally-wasted superseded | |
| # run. Main already required `false` (a cancelled merge-commit run | |
| # leaves that SHA's CI permanently incomplete — ADR-0034's | |
| # bisect-blind-spot concern, observed live 2026-08-12). | |
| cancel-in-progress: false | |
| 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 workflow-yaml job -- same script, plus | |
| # actionlint for semantic checks the parse can't see. Unconditional | |
| # like file-loc-limit: GitHub only parses a workflow when its trigger | |
| # fires, so a syntax error in a tag-triggered workflow otherwise | |
| # merges through green CI and detonates at release time (v0.2.0's | |
| # first run). | |
| workflow-lint: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - run: ./scripts/check-workflow-yaml.sh | |
| - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version: '1.25' | |
| cache: false | |
| - run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 | |
| - run: actionlint | |
| # Mirrors lefthook.yml's own comment-hygiene job -- same script | |
| # (.claude/rules/comments.md is the standard it enforces). Grep-only, | |
| # so like file-loc-limit it needs no build setup and no changes gate. | |
| comment-hygiene: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - run: ./scripts/check-comment-hygiene.sh | |
| # Mirrors lefthook.yml's own ui-copy job -- same script | |
| # (.claude/rules/ux-writing.md is the standard it enforces). | |
| ui-copy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - run: ./scripts/check-ui-copy.sh | |
| # Mirrors lefthook.yml's own toolbar-action-testids job -- same script | |
| # (the Atlas toolbar overflow contract, goal 0216/goal 0233). Grep-only, | |
| # so like comment-hygiene it needs no build setup and no changes gate. | |
| toolbar-action-testids: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - run: ./scripts/check-toolbar-action-testids.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 | |
| with: | |
| # Full history: the gocognit new-code gate below diffs against | |
| # the merge-base with origin/main, which a depth-1 clone | |
| # cannot compute. | |
| fetch-depth: 0 | |
| - 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 | |
| # Cognitive-complexity gate on NEW/CHANGED code only (goal 0109, | |
| # the clean-as-you-code posture -- see lefthook.yml's gocognit-new | |
| # job for the full reasoning). Reuses the binary the action above | |
| # already installed onto PATH. Requires the full-history checkout | |
| # this job's own fetch-depth: 0 provides (merge-base needs it). | |
| - run: golangci-lint run . ./internal/... --build-tags=server --enable-only gocognit --new-from-merge-base=origin/main --tests=false | |
| # Boundary-enforcement gate on NEW/CHANGED code only (goal 0168) -- | |
| # mirrors the gocognit-new invocation immediately above exactly, | |
| # see lefthook.yml's depguard-new job for the full reasoning. | |
| - run: golangci-lint run . ./internal/... --build-tags=server --enable-only depguard --new-from-merge-base=origin/main --tests=false | |
| 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 -coverprofile=cover.out && bash scripts/check-go-coverage.sh cover.out | |
| # Consumed by diff-coverage below -- reuse the profile this job | |
| # already produced instead of paying a second full -race run. | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: go-coverprofile | |
| path: cover.out | |
| # Changed-lines coverage (goal 0109 phase 2, the clean-as-you-code | |
| # counterpart to the gocognit gate): diff-cover reports how much of | |
| # THIS PR's changed lines the unit layers cover, from the Go | |
| # coverprofile (via gcov2lcov) + Vitest's lcov. INFORMATIONAL for | |
| # now -- continue-on-error and outside ci-gate's needs -- because | |
| # this repo's layering deliberately proves components in e2e, not | |
| # unit tests, so an industry-default fail-under would fail honest UI | |
| # PRs; the floor gets set from a track record of real PR numbers, | |
| # the same measure-then-promote shape as webview-bridge-smoke. | |
| diff-coverage: | |
| needs: [changes, frontend, test-go] | |
| if: success() && github.event_name == 'pull_request' && needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: go-coverprofile | |
| - 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: npm run test | |
| working-directory: frontend | |
| - run: go install github.com/jandelgado/gcov2lcov@v1.1.1 | |
| - run: gcov2lcov -infile cover.out -outfile go-lcov.info | |
| - run: pipx install diff-cover | |
| - name: Changed-lines coverage report | |
| run: | | |
| diff-cover go-lcov.info frontend/coverage/lcov.info \ | |
| --compare-branch "origin/${{ github.base_ref }}" \ | |
| --show-uncovered | |
| e2e: | |
| needs: [changes, frontend] | |
| if: success() && needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| # 25, was 15: the suite grew past the old budget in one day | |
| # (goal 0227 -- shards 1/4 exceeded 15m and GitHub marks timed-out | |
| # jobs "cancelled", which read as phantom cancellations for hours). | |
| timeout-minutes: 25 | |
| 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: | |
| # 6-way (goal 0227 S2): 4-way shards were exceeding even the | |
| # raised 25-minute budget under per-test dedicated-server boot | |
| # cost. Only "CI gate" is ruleset-required, and ci-gate needs | |
| # the e2e JOB id (not per-shard display names), so shard count | |
| # changes here never touch branch protection. | |
| shardIndex: [1, 2, 3, 4, 5, 6] | |
| shardTotal: [6] | |
| 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 | |
| # Browser binaries cached by Playwright version: the uncached | |
| # `install --with-deps` path stalls in its apt phase on flaky | |
| # ubuntu mirrors (three 15-minute shard hangs observed in one | |
| # day), and the runner image already carries Chromium's system | |
| # deps -- so a cache hit skips the whole install, and only a | |
| # real Playwright version bump pays the download again (the | |
| # upstream-documented CI caching pattern). | |
| - name: Resolve Playwright version | |
| id: pw-version | |
| working-directory: frontend | |
| run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" | |
| - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| id: pw-cache | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-chromium-${{ runner.os }}-${{ steps.pw-version.outputs.version }} | |
| # No --with-deps: the ubuntu runner image already ships | |
| # Chromium's system libraries, and the apt phase behind | |
| # --with-deps is the exact stall (archive.ubuntu.com hanging | |
| # until the job's 15-minute cap) this caching exists to kill -- | |
| # observed persisting even on cache-miss runs. | |
| - if: steps.pw-cache.outputs.cache-hit != 'true' | |
| run: npx playwright install 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 | |
| # The real-webview parity smoke used to run here as a non-required | |
| # job. It could not hold green on hosted runners, so it moved to | |
| # .github/workflows/webview-smoke.yml (scheduled + manual dispatch). | |
| # A permanently-red check in the PR baseline trains red-blindness and | |
| # hides the next real failure, so the PR/main baseline carries only | |
| # checks that hold green -- see .claude/rules/delivery-discipline.md | |
| # and docs/goals/0134-pipeline-reliability.md for the path back in. | |
| 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 | |
| - workflow-lint | |
| - comment-hygiene | |
| - ui-copy | |
| - toolbar-action-testids | |
| - 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." | |
| # goal 0100: a rolling beta prerelease for every green merge to main, | |
| # so the owner can dogfood a build without a local rebuild. Gated on | |
| # `needs.ci-gate.result` (the SAME ci-gate this push-triggered run | |
| # already computed above -- ci.yml's own `on:` already runs the whole | |
| # gate on push-to-main, not just pull_request, per ADR-0034's | |
| # post-merge-verification concurrency-group comment), never a second | |
| # workflow_run indirection. macOS-only + contents:write, same reasons | |
| # release.yml's build-macos/release jobs already carry. | |
| beta-release: | |
| needs: ci-gate | |
| # !cancelled() replaces the implicit success() status function -- | |
| # without it, any legitimately-skipped job in the TRANSITIVE needs | |
| # chain (dependency-review is PR-only, changes-filtered jobs skip | |
| # on docs-only pushes) auto-skips this job even when ci-gate itself | |
| # succeeded; observed on the first green push run after the job | |
| # landed (skipped, 0 steps, green gate). | |
| if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.ci-gate.result == 'success' }} | |
| runs-on: macos-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: write | |
| # Read-only: "Compute beta version" below queries the open | |
| # release-please PR for the next version number. | |
| pull-requests: read | |
| # Newest merge wins -- an in-flight beta-release run for an older | |
| # commit is redundant the moment a newer merge lands, so it's | |
| # cancelled rather than left to publish a stale rolling beta after | |
| # the newer one. | |
| concurrency: | |
| group: beta-release | |
| cancel-in-progress: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - 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 | |
| - name: Install Task and wails3 CLI | |
| run: | | |
| go install github.com/go-task/task/v3/cmd/task@v3.52.0 | |
| go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.6 | |
| # BETA_VERSION must be valid, monotonically increasing SemVer -- | |
| # not just a bare "beta" tag -- because main.go's millUpdateVersion | |
| # doc comment explains why: wails3/pkg/updater's GitHub provider | |
| # compares release TagName via SemVer precedence, where a | |
| # prerelease always ranks below its corresponding release, so a | |
| # non-SemVer or non-increasing tag would never register as an | |
| # available update. GITHUB_RUN_NUMBER is ci.yml's own | |
| # monotonically increasing counter (never resets), reused here | |
| # rather than a timestamp or SHA for exactly that guarantee. | |
| # | |
| # The base MUST be the NEXT release, not main.go's currently- | |
| # shipped millVersion: a prerelease's own base version also | |
| # ranks by SemVer precedence, so basing betas on the released | |
| # version they postdate (0.5.0-beta.N while v0.5.0 already | |
| # exists) puts every beta BELOW the stable release it comes | |
| # after -- backwards, and the same bug class the comment above | |
| # already guards against one level up. release-please's own | |
| # open PR (branch release-please--branches--main, title | |
| # "chore(main): release X.Y.Z") already computed the next | |
| # version from Conventional Commits on main, so read it there | |
| # first. Fall back to bumping millVersion's MINOR (X.Y.Z -> | |
| # X.(Y+1).0) only when that PR is absent or unparseable -- | |
| # release-please's release-type:go defaults to a feat-driven | |
| # minor bump for this repo's commit history, so the fallback | |
| # matches its own convention. | |
| - name: Compute beta version | |
| run: | | |
| BASE_VERSION=$(grep -m1 'const millVersion' main.go | sed -E 's/.*"([0-9][0-9A-Za-z.+-]*)".*/\1/') | |
| NEXT_VERSION=$(gh pr list --repo "${GITHUB_REPOSITORY}" \ | |
| --head release-please--branches--main --state open \ | |
| --json title -q '.[0].title // empty' \ | |
| | sed -E -n 's/.*release ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p') | |
| if [ -z "$NEXT_VERSION" ]; then | |
| NEXT_VERSION=$(echo "$BASE_VERSION" | awk -F. '{printf "%d.%d.0", $1, $2+1}') | |
| echo "::notice::no open release-please PR (or unparseable title) -- falling back to minor-bumped $NEXT_VERSION from millVersion $BASE_VERSION" | |
| fi | |
| echo "BETA_VERSION=${NEXT_VERSION}-beta.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV" | |
| # task package already ad-hoc codesigns (build/darwin/Taskfile.yml's | |
| # create:app:bundle -> codesign:adhoc) -- goal 0100's own DoR | |
| # research confirmed ad-hoc signing + a documented first-run | |
| # `xattr -dr com.apple.quarantine` step is the converged practice | |
| # for distributing unsigned CI-built macOS apps, same as | |
| # release.yml's own unsigned/ad-hoc posture (no paid Developer ID | |
| # cert exists; notarization stays out of scope for either channel). | |
| - run: task package | |
| env: | |
| MILL_SKIP_BINDINGS: "1" | |
| MILL_CHANNEL: beta | |
| MILL_UPDATE_VERSION: ${{ env.BETA_VERSION }} | |
| # Guard: the beta version stamp must actually be IN the binary. | |
| # The ldflags -X value embeds the literal string, so a plain | |
| # binary grep proves the build consumed MILL_UPDATE_VERSION -- | |
| # this is the check that would have caught the stamp silently | |
| # never being wired into build/darwin/Taskfile.yml's ldflags | |
| # (every beta reported the bare release version and offered its | |
| # own release as an update). | |
| - name: Verify beta version stamp reached the binary | |
| run: | | |
| BIN="bin/mill.app/Contents/MacOS/mill" | |
| grep -aqF "$BETA_VERSION" "$BIN" || { echo "::error::binary at $BIN does not contain $BETA_VERSION -- MILL_UPDATE_VERSION was not stamped"; exit 1; } | |
| # scripts/package-macos-zip.sh: the same asset-naming contract | |
| # release.yml's build-macos job uses -- one definition, so a beta | |
| # and a real release can never drift in asset naming. Staged into | |
| # a clean dist/ dir so the checksum step below is a literal, | |
| # byte-identical copy of release.yml's own "Generate checksums" | |
| # step (goal 0100 addendum: one pinned payload contract, | |
| # channel-independent). | |
| - run: scripts/package-macos-zip.sh "$BETA_VERSION" bin/mill.app dist | |
| - name: Generate checksums | |
| working-directory: dist | |
| run: sha256sum -- * > SHA256SUMS | |
| # Draft-then-publish (goal 0205 S2): the beta provider walks | |
| # /repos/{r}/releases?per_page=10 and explicitly skips draft | |
| # entries by design (confirmed against the vendored source, | |
| # pkg/updater/providers/github/github.go's fetchRelease) -- a | |
| # release created as a draft is therefore invisible to every | |
| # client's Check() until this job flips it published, which now | |
| # happens only AFTER every asset is attached. That removes the | |
| # window where a client could observe the newest release before | |
| # its assets exist. | |
| - name: Create beta prerelease (draft) | |
| # Two audiences, one file (goal 0127): the section ABOVE the | |
| # in-app-notes-end marker is what the in-app update card shows | |
| # (trimReleaseNotesForApp, settingsservice_updates.go) -- the | |
| # merged change's own title, real what's-new. Everything below | |
| # is the GitHub releases page's manual-install path, which is | |
| # nonsense inside an app about to update itself. | |
| run: | | |
| MERGE_TITLE="$(git log -1 --pretty=%s)" | |
| { | |
| echo "## What's new" | |
| echo "" | |
| echo "- ${MERGE_TITLE}" | |
| echo "" | |
| echo "<!-- in-app-notes-end -->" | |
| echo "## Manual install" | |
| echo "" | |
| echo "Automated build from commit ${GITHUB_SHA} -- not a tagged release." | |
| echo "Download the \`.zip\`, unzip, and drag \`mill.app\` to Applications. The app is not Apple-notarized: first launch is blocked with \"Apple could not verify…\" -- click Done (not Move to Trash), then System Settings → Privacy & Security → scroll to the mill message → Open Anyway (one time only). Terminal alternative: \`/usr/bin/xattr -dr com.apple.quarantine /Applications/mill.app\`." | |
| echo "Every later merge to main updates in-app via Settings → Updates → Update now -- no rebuild, no repeat of this step." | |
| } > /tmp/beta-notes.md | |
| gh release create "v${BETA_VERSION}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --title "Beta v${BETA_VERSION}" \ | |
| --prerelease \ | |
| --draft \ | |
| --notes-file /tmp/beta-notes.md | |
| - name: Upload beta assets | |
| run: gh release upload "v${BETA_VERSION}" --repo "${GITHUB_REPOSITORY}" dist/* | |
| - name: Publish beta prerelease | |
| run: gh release edit "v${BETA_VERSION}" --repo "${GITHUB_REPOSITORY}" --draft=false | |
| # Rolling channel, not a rolling tag: each beta release gets its | |
| # own SemVer tag (required for update detection, see "Compute | |
| # beta version" above), so "rolling" means at most one PUBLISHED | |
| # beta prerelease exists at a time -- delete every older one only | |
| # AFTER the new one is live (never before: deleting first would | |
| # reopen the exact window this job now closes, by leaving no | |
| # beta release at all for the length of the create/upload/publish | |
| # sequence above). isPrerelease filters real tagged releases out | |
| # categorically; real releases are never touched by this job. The | |
| # tag just published is excluded by name so this step can never | |
| # delete what it just created. | |
| - name: Delete previous beta prereleases | |
| run: | | |
| gh release list --repo "${GITHUB_REPOSITORY}" --json tagName,isPrerelease \ | |
| -q ".[] | select(.isPrerelease and .tagName != \"v${BETA_VERSION}\") | .tagName" | while read -r tag; do | |
| gh release delete "$tag" --repo "${GITHUB_REPOSITORY}" --yes --cleanup-tag | |
| done |