Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,106 @@ jobs:
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
if: 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/*
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,14 @@ jobs:
env:
MILL_SKIP_BINDINGS: "1"
MILL_CHANNEL: release
# scripts/package-macos-zip.sh: the shared asset-naming contract
# goal 0100's beta-release job (ci.yml) also calls -- one
# definition, so a beta and a real release can never drift in
# asset naming.
- name: Zip app bundle with platform/arch/version suffix
run: |
VERSION="${GITHUB_REF_NAME#v}"
ditto -c -k --keepParent bin/mill.app "bin/mill-${VERSION}-macos-$(uname -m).zip"
scripts/package-macos-zip.sh "$VERSION" bin/mill.app bin
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: bin/mill-*.zip
Expand Down
9 changes: 6 additions & 3 deletions frontend/e2e/fixtures/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ export const SCALE_MCP_BASE_PORT = 9730
// workers' seeds either.
export const MIRROR_SERVER_BASE_PORT = 9690
export const MIRROR_MCP_BASE_PORT = 9740
// updates.spec.ts's own two disjoint pairs (goal 0082) -- one server
// per channel, since MILL_TEST_UPDATE_CHANNEL is fixed for a process's
// whole lifetime and both channels' UI need proving in the same run.
// updates.spec.ts's own disjoint pairs (goal 0082, beta pair added
// goal 0100) -- one server per channel, since MILL_TEST_UPDATE_CHANNEL
// is fixed for a process's whole lifetime and every channel's UI needs
// proving in the same run.
export const UPDATES_SOURCE_SERVER_BASE_PORT = 9760
export const UPDATES_SOURCE_MCP_BASE_PORT = 9780
export const UPDATES_RELEASE_SERVER_BASE_PORT = 9790
export const UPDATES_RELEASE_MCP_BASE_PORT = 9810
export const UPDATES_BETA_SERVER_BASE_PORT = 9815
export const UPDATES_BETA_MCP_BASE_PORT = 9825
// guardrail-authoring.spec.ts's own dedicated pair (goal 0078): the
// full rule-from-park -> unstick -> audit-edit -> policy-removed loop
// asserts exact rule counts/groupings in the Rules audit view, which
Expand Down
57 changes: 47 additions & 10 deletions frontend/e2e/updates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,29 @@ import path from 'node:path'
import {
spawnMillServer,
type SpawnedServer,
UPDATES_BETA_MCP_BASE_PORT,
UPDATES_BETA_SERVER_BASE_PORT,
UPDATES_RELEASE_MCP_BASE_PORT,
UPDATES_RELEASE_SERVER_BASE_PORT,
UPDATES_SOURCE_MCP_BASE_PORT,
UPDATES_SOURCE_SERVER_BASE_PORT,
} from './fixtures/server'

// goal 0082: channel-aware updates. Both channels render from the same
// binary, distinguished only by MILL_TEST_UPDATE_CHANNEL -- a real
// release-channel build is stamped at compile time (main.go's
// millChannel, ldflags-overridden by release.yml), so proving BOTH
// branches of the UI needs this env seam instead. MILL_TEST_UPDATE_
// goal 0082: channel-aware updates (beta channel added goal 0100).
// Every channel renders from the same binary, distinguished only by
// MILL_TEST_UPDATE_CHANNEL -- a real release/beta-channel build is
// stamped at compile time (main.go's millChannel, ldflags-overridden
// by release.yml / ci.yml's beta-release job), so proving every
// branch of the UI needs this env seam instead. MILL_TEST_UPDATE_
// FAKE_VERSION makes CheckForUpdates return a canned "update
// available" result with no network call, so the available-update card
// renders deterministically. Never click "Update now" here -- the real
// download/verify/swap/restart path is OS-bound and can only be proven
// against a genuine newer GitHub release from an installed
// release-channel build (see testing.md's manual-only registry entry).
// Deliberately bypasses the standard per-worker fixture (same reasoning
// as persistence.spec.ts): each test needs its own server carrying a
// download/verify/swap/restart path is OS-bound (and, since goal 0100,
// gated on a real pre-swap backup) and can only be proven against a
// genuine newer GitHub release from an installed release/beta-channel
// build (see testing.md's manual-only registry entry). Deliberately
// bypasses the standard per-worker fixture (same reasoning as
// persistence.spec.ts): each test needs its own server carrying a
// fixed MILL_TEST_UPDATE_* env for its whole lifetime, on its own
// disjoint port pair.

Expand Down Expand Up @@ -113,3 +117,36 @@ test('Release-channel build shows the primary Update now button and no source hi
await browser.close()
}
})

// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
test('Beta-channel build shows the primary Update now button and the beta channel label', async ({}, testInfo) => {
const idx = testInfo.parallelIndex
let server: SpawnedServer | undefined
let dir: string | undefined
const browser = await chromium.launch()
try {
;({ server, dir } = await spawnUpdatesServer(idx, UPDATES_BETA_SERVER_BASE_PORT, UPDATES_BETA_MCP_BASE_PORT, {
MILL_TEST_UPDATE_FAKE_VERSION: '9.9.9',
MILL_TEST_UPDATE_CHANNEL: 'beta',
}))
const page = await browser.newPage()
await page.goto(`${server.baseURL}/`)
await page.getByRole('link', { name: 'Settings' }).click()

await expect(page.getByTestId('current-app-version')).toContainText('installed from the beta channel')

await page.getByTestId('check-for-updates').click()
const card = page.getByTestId('update-available-card')
await expect(card).toBeVisible()

await expect(card.getByTestId('update-now')).toBeVisible()
await expect(card.getByTestId('update-now')).toHaveText('Update now')
await expect(card).not.toContainText('This copy was built from source')

await page.close()
} finally {
await server?.stop()
if (dir) rmSync(dir, { recursive: true, force: true })
await browser.close()
}
})
1 change: 1 addition & 0 deletions frontend/src/locales/en/views.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"upToDate": "You're on the latest version.",
"currentVersion": "You have Mill v{{version}}",
"channelRelease": "installed from a release",
"channelBeta": "installed from the beta channel",
"channelSource": "built from source",
"whatsNew": "What's new",
"updateNow": "Update now",
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/views/UpdatesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import monoStyles from '../shared/monoText.module.css'

// Extracted from SettingsView.tsx (same reason DataStewardshipSection
// already is: keeps that file's own line count from crowding the
// 500-line convention). Two channel behaviors sharing one surface: a
// release-channel build can install and restart itself; a
// 500-line convention). Two install behaviors sharing one surface:
// release and beta builds can install and restart themselves; a
// source-channel build only ever notifies and points at a rebuild.

type Channel = '' | 'source' | 'release'
type Channel = '' | 'source' | 'release' | 'beta'
const installableChannels: Channel[] = ['release', 'beta']
type InstallState = 'idle' | 'installing' | 'installed' | 'failed'

interface UpdateResult {
Expand Down Expand Up @@ -67,7 +68,13 @@ function UpdatesSection() {
SettingsService.RestartApp().catch((err) => setInstallError(String(err)))
}

const channelLabel = channel === 'release' ? t('settings.updates.channelRelease') : t('settings.updates.channelSource')
const channelLabel =
channel === 'release'
? t('settings.updates.channelRelease')
: channel === 'beta'
? t('settings.updates.channelBeta')
: t('settings.updates.channelSource')
const canInstall = installableChannels.includes(channel)
const statusText = checking ? t('settings.updates.checking') : status

return (
Expand Down Expand Up @@ -106,7 +113,7 @@ function UpdatesSection() {
</details>
)}

{channel === 'release' ? (
{canInstall ? (
<>
{installState !== 'installed' ? (
<Button
Expand Down
71 changes: 71 additions & 0 deletions internal/services/backupsvc/backupservice_versionguard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package backupsvc

import (
"fmt"
"log/slog"

"github.com/alicoding/mill/internal/adapters/backup"
"github.com/alicoding/mill/internal/adapters/settings"
)

// lastSeenVersionKey persists the version stamp SnapshotOnVersionChange
// compares against on every launch -- a settings.Store key, so it lives
// beside settings.json (the same directory backup.Snapshot also copies
// into every snapshot), not inside execution.db itself: a downgraded
// relaunch (an older binary) must still be able to read this key with
// its own, older settings.Store implementation, which a value stored
// only inside the newer schema's execution.db could not guarantee.
const lastSeenVersionKey = "settings-last-seen-version"

// SnapshotOnVersionChange is goal 0100's belt-and-suspenders data-safety
// guard: called once from main.go, before any other service opens or
// migrates execution.db, on every launch regardless of channel --
// covers both an in-app update (which DownloadAndInstallUpdate's own
// pre-swap backup already snapshots, so this is redundant-but-cheap
// there) and the source-channel pull+rebuild path, where no updater
// ever runs and this is the ONLY snapshot guard.
//
// A missing stamp (first launch ever against this data directory) is
// not a version change -- nothing to protect yet -- so it only records
// the current version. An unchanged stamp is a no-op. A changed stamp
// (upgrade or downgrade) snapshots first and only then advances the
// stamp, so a snapshot that fails leaves the stamp untouched and the
// next launch retries it rather than silently skipping forever.
//
// dbPath == "" (a BYO-Postgres deployment, docs/goals/0065 item 6) has
// nothing for VACUUM INTO to copy; the version stamp still advances so
// a non-sqlite deployment isn't stuck re-attempting an unavailable
// snapshot on every single launch.
func SnapshotOnVersionChange(store settings.Store, dbPath, settingsPath, dir, currentVersion string) (didBackup bool, err error) {
stamp, _ := store.Get(lastSeenVersionKey).(string)
if stamp == currentVersion {
return false, nil
}
if stamp != "" && dbPath != "" {
if _, err := backup.Snapshot(dbPath, settingsPath, dir, DefaultKeepN); err != nil {
return false, fmt.Errorf("version-change snapshot (%s -> %s): %w", stamp, currentVersion, err)
}
didBackup = true
}
if err := store.Set(lastSeenVersionKey, currentVersion); err != nil {
return didBackup, fmt.Errorf("persist version stamp after snapshot: %w", err)
}
return didBackup, nil
}

// GuardVersionChange wraps SnapshotOnVersionChange with main.go's own
// logging, extracted so main.go's wiring stays terse (the same
// keep-main.go-under-its-line-count reasoning InitUpdater's own
// extraction already documents). Best-effort, matching
// BackupOnCleanShutdown's own non-fatal posture: a failed snapshot
// here is logged, never fatal -- it must never make Mill unbootable.
func GuardVersionChange(logger *slog.Logger, store settings.Store, dbPath, settingsPath, dir, currentVersion string) {
didBackup, err := SnapshotOnVersionChange(store, dbPath, settingsPath, dir, currentVersion)
if err != nil {
logger.Error("version-change snapshot", "error", err)
return
}
if didBackup {
logger.Info("version-change snapshot taken", "version", currentVersion)
}
}
Loading
Loading