feat: the clipboard bridge -- agent loop without MCP (goal 0099) (#233) #519
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 }} | |
| # 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 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 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 -coverprofile=cover.out && bash scripts/check-go-coverage.sh cover.out | |
| 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: | |
| # 4-way since the guardrail specs moved to per-test dedicated | |
| # servers: the added boot time pushed two 3-way shards past | |
| # the 15-minute job cap with zero failing tests -- the exact | |
| # revisit trigger testing.md records for shard growth. | |
| shardIndex: [1, 2, 3, 4] | |
| shardTotal: [4] | |
| 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 | |
| # docs/goals/0097 (re-scoped): the real-webview parity smoke | |
| # (scripts/webview-bridge-smoke.sh, internal/webviewbridgesmoke) drives | |
| # a genuine -tags mcp desktop build over Wails3's own MCP bridge -- | |
| # the only mechanism that exercises macOS's real WKWebView engine, | |
| # since Playwright's "webkit" build never attaches to it (researched- | |
| # and-rejected premise, .claude/rules/testing.md). GitHub-hosted | |
| # macos-latest runners DO support a real windowed app with no xvfb- | |
| # style workaround needed (confirmed via a working precedent: Simon | |
| # Willison's Datasette Desktop runs real-window Playwright Electron | |
| # tests on macos-latest with zero special display config -- the | |
| # self-hosted-runner GUI-session caveats found in research are a | |
| # self-hosted concern, GitHub's own hosted image already provides a | |
| # console session). Non-required (`continue-on-error`, absent from | |
| # ci-gate's `needs:` below) until it's proven stable in real CI runs -- | |
| # promote once it has a track record, per the goal's own CI- | |
| # feasibility instruction not to iterate on live CI cycles up front. | |
| webview-bridge-smoke: | |
| needs: [changes, frontend] | |
| if: success() && needs.changes.outputs.code == 'true' | |
| runs-on: macos-latest | |
| timeout-minutes: 15 | |
| continue-on-error: true | |
| 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 | |
| - run: npm ci | |
| working-directory: frontend | |
| - run: ./scripts/webview-bridge-smoke.sh | |
| 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 | |
| - 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 | |
| # 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. | |
| - name: Compute beta version | |
| run: | | |
| BASE_VERSION=$(grep -m1 'const millVersion' main.go | sed -E 's/.*"([0-9][0-9A-Za-z.+-]*)".*/\1/') | |
| echo "BETA_VERSION=${BASE_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 }} | |
| # 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 | |
| # 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 beta | |
| # prerelease exists at a time -- delete every older one first. | |
| # isPrerelease filters real tagged releases out categorically; | |
| # real releases are never touched by this job. | |
| - name: Delete previous beta prereleases | |
| run: | | |
| gh release list --repo "${GITHUB_REPOSITORY}" --json tagName,isPrerelease \ | |
| -q '.[] | select(.isPrerelease) | .tagName' | while read -r tag; do | |
| gh release delete "$tag" --repo "${GITHUB_REPOSITORY}" --yes --cleanup-tag | |
| done | |
| - name: Create beta prerelease | |
| run: | | |
| { | |
| echo "## Beta build" | |
| 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: \`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 \ | |
| --notes-file /tmp/beta-notes.md \ | |
| dist/* |