Skip to content

fix: an empty note is creatable -- placement is the capture (#234) #521

fix: an empty note is creatable -- placement is the capture (#234)

fix: an empty note is creatable -- placement is the capture (#234) #521

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 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/*