From 26c802415d63a161d942b3b90c1185b20d06d417 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Mon, 17 Aug 2026 23:44:46 -0400 Subject: [PATCH] feat: beta channel -- rolling pre-release feed + pre-swap/version-change backup guards (goal 0100) Extends 0082's channel-aware updater with a third channel: a beta-stamped build targets a rolling GitHub prerelease (SemVer-tagged per build, since wails3/pkg/updater's TagName comparison ranks any non-SemVer or non-increasing tag as never-newer) instead of the release channel's /releases/latest. Owner-mandated data-safety addendum: DownloadAndInstallUpdate now takes a backup through an injected seam before every swap and aborts on failure, and a launch-time version-stamp guard (backupsvc.SnapshotOnVersionChange) covers the source-channel pull+rebuild path where no updater ever runs. Release and beta assets now share one naming/checksum script (scripts/package-macos-zip.sh) so the two channels can never drift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd --- .github/workflows/ci.yml | 103 ++++++++++++++++ .github/workflows/release.yml | 6 +- frontend/e2e/fixtures/server.ts | 9 +- frontend/e2e/updates.spec.ts | 57 +++++++-- frontend/src/locales/en/views.json | 1 + frontend/src/views/UpdatesSection.tsx | 17 ++- .../backupsvc/backupservice_versionguard.go | 71 +++++++++++ .../backupservice_versionguard_test.go | 115 ++++++++++++++++++ .../backupsvc/backupservice_wiring.go | 13 ++ .../services/settingssvc/settingsservice.go | 6 + .../settingssvc/settingsservice_updates.go | 66 ++++++++-- .../settingsservice_updates_test.go | 99 ++++++++++++++- main.go | 56 ++++----- scripts/package-macos-zip.sh | 15 +++ 14 files changed, 573 insertions(+), 61 deletions(-) create mode 100644 internal/services/backupsvc/backupservice_versionguard.go create mode 100644 internal/services/backupsvc/backupservice_versionguard_test.go create mode 100755 scripts/package-macos-zip.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e5dd2b6..09773c20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 630a425f..f145a7ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/frontend/e2e/fixtures/server.ts b/frontend/e2e/fixtures/server.ts index f145b87a..c57e4bbc 100644 --- a/frontend/e2e/fixtures/server.ts +++ b/frontend/e2e/fixtures/server.ts @@ -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 diff --git a/frontend/e2e/updates.spec.ts b/frontend/e2e/updates.spec.ts index fd31afeb..baa81d9e 100644 --- a/frontend/e2e/updates.spec.ts +++ b/frontend/e2e/updates.spec.ts @@ -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. @@ -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() + } +}) diff --git a/frontend/src/locales/en/views.json b/frontend/src/locales/en/views.json index 8e25f5f9..d245da4e 100644 --- a/frontend/src/locales/en/views.json +++ b/frontend/src/locales/en/views.json @@ -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", diff --git a/frontend/src/views/UpdatesSection.tsx b/frontend/src/views/UpdatesSection.tsx index 31ba75d1..5d5dc597 100644 --- a/frontend/src/views/UpdatesSection.tsx +++ b/frontend/src/views/UpdatesSection.tsx @@ -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 { @@ -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 ( @@ -106,7 +113,7 @@ function UpdatesSection() { )} - {channel === 'release' ? ( + {canInstall ? ( <> {installState !== 'installed' ? (