diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 73e31411..0aa8f59c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,12 +1,15 @@ name: Nightly # Builds main every night (and on demand) for every platform and replaces the rolling `nightly` -# GitHub prerelease with its setups and portable packages. A nightly is download-only: it is on -# no update feed, needs no signing key and never enters the `release` environment. +# GitHub prerelease with its setups and portable packages. With a nightly key (the `nightly` +# environment, see docs/how-to/release.md) it also signs update-nightly.json, which clients on +# 每日构建 follow, and puts the update packages next to it on the `updates` release. The production +# key and the `release` environment are never used here. # -# Version: -nightly., e.g. 1.0.3-nightly.20261108. It ranks below the -# release it leads up to, so that release replaces it. The scheduled run skips a night when main -# has not moved since the last nightly; a manual run always builds. +# Version: -nightly.., e.g. 1.0.3-nightly.20261108.42 (nightlyVersion in +# tools/release/lib/version.mjs). It ranks below the release it leads up to, so that release replaces +# it. The scheduled run skips a night when main has not moved since the last nightly; a manual run +# always builds. on: schedule: - cron: '0 18 * * *' # 02:00 Beijing time @@ -28,6 +31,7 @@ jobs: build: ${{ steps.version.outputs.build }} version: ${{ steps.version.outputs.version }} sha: ${{ steps.version.outputs.sha }} + last: ${{ steps.version.outputs.last }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -40,6 +44,7 @@ jobs: name: Version from package.json and the date env: EVENT: ${{ github.event_name }} + RUN: ${{ github.run_number }} run: | sha=$(git rev-parse HEAD) last=$(git ls-remote origin refs/tags/nightly | cut -f1) @@ -48,21 +53,19 @@ jobs: echo "build=false" >> "$GITHUB_OUTPUT" exit 0 fi - # The next release: package.json, or one patch later when that version already shipped. version=$(node --no-warnings --input-type=module -e ' import { execFileSync } from "node:child_process" import { readFileSync } from "node:fs" - import { parseVersion } from "./tools/release/lib/version.mjs" - const v = parseVersion(JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version) - let core = `${v.major}.${v.minor}.${v.patch}` + import { nightlyVersion } from "./tools/release/lib/version.mjs" const tags = execFileSync("git", ["tag", "--list"], { encoding: "utf8" }).split("\n") - if (tags.includes(core) || tags.includes(`v${core}`)) core = `${v.major}.${v.minor}.${v.patch + 1}` - console.log(`${core}-nightly.${process.argv[1]}`) - ' "$(date -u +%Y%m%d)") + const pkg = JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version + console.log(nightlyVersion(pkg, tags, new Date(), Number(process.argv[1]))) + ' "$RUN") echo "nightly $version from $sha" echo "build=true" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "last=$last" >> "$GITHUB_OUTPUT" build: name: Build ${{ matrix.platform }} @@ -128,46 +131,120 @@ jobs: - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: nightly-${{ matrix.platform }} - # Downloads only: the full package and file list exist for updates, which nightlies are not part of. + # Setups and portable packages for the `nightly` release; full packages and file lists for updates. path: | - dist/release/artifacts/MossStudio-Setup-* - dist/release/artifacts/MossStudio-*-portable.* - dist/release/artifacts/MossStudio-*.dmg + dist/release/artifacts/MossStudio-* + dist/release/artifacts/files-* if-no-files-found: error compression-level: 0 retention-days: 3 publish: - name: Replace the nightly release + name: Update feed and the nightly release needs: [prepare, build] runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 + # Holds MOSS_NIGHTLY_SIGNING_KEY. Limit the environment to `main`; it needs no reviewer, because + # clients trust its key for update-nightly.json only (NIGHTLY_UPDATE_KEYS). + environment: nightly permissions: contents: write env: VERSION: ${{ needs.prepare.outputs.version }} SHA: ${{ needs.prepare.outputs.sha }} + LAST: ${{ needs.prepare.outputs.last }} GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + ART: dist/release/artifacts + PREV: dist/release/previous steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.sha }} + fetch-depth: 0 + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + - name: Install the runtime packages the checks use (yauzl, zod) + run: pnpm install --frozen-lockfile --prod --ignore-scripts - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: nightly-* - path: nightly + path: dist/release/artifacts merge-multiple: true - name: Notes run: | + # The commits since the last nightly, when it is an ancestor (a force-push can drop it). + subjects=() + if [ -n "$LAST" ] && git merge-base --is-ancestor "$LAST" "$SHA" 2>/dev/null; then + mapfile -t subjects < <(git log --no-merges --format=%s "$LAST..$SHA") + fi + node --input-type=module -e ' + import { nightlyNotes } from "./tools/release/lib/feed.mjs" + const [sha, repo, ...subjects] = process.argv.slice(1) + console.log(nightlyNotes({ sha, repo, subjects })) + ' "$SHA" "$GH_REPO" "${subjects[@]}" > feed-notes.md cat > notes.md <> "$GITHUB_OUTPUT" + exit 0 + fi + node --no-warnings tools/release/fetch-nightly.mjs --out "$PREV" + node tools/release/make-deltas.mjs --version "$VERSION" --previous "$PREV" --artifacts "$ART" + node tools/release/make-feed.mjs --nightly --version "$VERSION" --artifacts "$ART" --notes-file feed-notes.md --current-nightly "$PREV/current-update-nightly.json" + if [ ! -f "$ART/update-nightly.json" ]; then echo "feed=false" >> "$GITHUB_OUTPUT"; exit 0; fi + node tools/release/sign-feed.mjs "$ART/update-nightly.json" + # Fails unless the key's public half is in NIGHTLY_UPDATE_KEYS, so clients will accept the feed. + node --no-warnings tools/release/verify-artifacts.mjs --version "$VERSION" --artifacts "$ART" --previous "$PREV" --nightly-keys + echo "feed=true" >> "$GITHUB_OUTPUT" + - name: Update packages and the feed on the rolling `updates` release + if: steps.feed.outputs.feed == 'true' + run: | + if ! gh release view updates >/dev/null 2>&1; then + gh release create updates --title "Update feeds" --notes "Signed update feeds (update-.json + .sig) read by MossStudio, and the latest nightly's update packages. Replaced by every release and nightly; not a download." --prerelease --latest=false --target "$SHA" + fi + shopt -s nullglob + packages=("$ART"/MossStudio-"$VERSION"-*-full.zip "$ART"/files-"$VERSION"-*.json "$ART"/MossStudio-*-to-"$VERSION"-*.delta.zip) + gh release upload updates "${packages[@]}" --clobber + # The signature first: a client that reads the new .sig with the old feed fails the + # check and retries later; it never trusts anything unverified. + gh release upload updates "$ART/update-nightly.json.sig" --clobber + gh release upload updates "$ART/update-nightly.json" --clobber + # Only now that the feed names the new packages: drop older nightlies' ones, keeping the + # previous nightly's so a download that started from the old feed can finish. + previous="" + if [ -f "$PREV/current-update-nightly.json" ]; then + previous=$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version' "$PREV/current-update-nightly.json") + fi + gh release view updates --json assets -q '.assets[].name' > updates-assets.txt + node --input-type=module -e ' + import { readFileSync } from "node:fs" + import { staleNightlyPackages } from "./tools/release/lib/version.mjs" + const names = readFileSync("updates-assets.txt", "utf8").split("\n").filter(Boolean) + for (const name of staleNightlyPackages(names, process.argv.slice(1))) console.log(name) + ' "$VERSION" "$previous" > stale.txt + while read -r name; do gh release delete-asset updates "$name" --yes; done < stale.txt - name: Replace release `nightly` run: | # Delete first so the tag moves to the new commit and no old file lingers. if gh release view nightly >/dev/null 2>&1; then gh release delete nightly --cleanup-tag --yes; fi git ls-remote --exit-code "https://github.com/$GH_REPO" refs/tags/nightly >/dev/null 2>&1 && gh api -X DELETE "repos/$GH_REPO/git/refs/tags/nightly" - gh release create nightly nightly/* --target "$SHA" --title "MossStudio $VERSION(每日构建)" --notes-file notes.md --prerelease --latest=false + shopt -s nullglob + downloads=("$ART"/MossStudio-Setup-* "$ART"/MossStudio-*-portable.* "$ART"/MossStudio-*.dmg) + gh release create nightly "${downloads[@]}" --target "$SHA" --title "MossStudio $VERSION(每日构建)" --notes-file notes.md --prerelease --latest=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49bcc59c..fd566c74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,7 +188,7 @@ jobs: - name: Replace the feeds on the rolling `updates` release run: | if ! gh release view updates >/dev/null 2>&1; then - gh release create updates --title "Update feeds" --notes "Signed update feeds (update-.json + .sig) read by MossStudio. Replaced by every release; not a download." --prerelease --latest=false --target "$GITHUB_SHA" + gh release create updates --title "Update feeds" --notes "Signed update feeds (update-.json + .sig) read by MossStudio, and the latest nightly's update packages. Replaced by every release and nightly; not a download." --prerelease --latest=false --target "$GITHUB_SHA" fi shopt -s nullglob # The signature first: a client that reads the new .sig with the old feed fails the diff --git a/apps/desktop/e2e/specs/update.e2e.ts b/apps/desktop/e2e/specs/update.e2e.ts index ab5b87c7..f513ab38 100644 --- a/apps/desktop/e2e/specs/update.e2e.ts +++ b/apps/desktop/e2e/specs/update.e2e.ts @@ -365,6 +365,11 @@ describe('设置 › 通知与更新', () => { await channel.getByRole('button', { name: '测试版' }).click() await expect.poll(() => app.calls('update:set-channel')).toEqual([{ channel: 'beta' }]) await expect.poll(() => channel.getByRole('button', { name: '测试版' }).getAttribute('aria-pressed')).toBe('true') + await channel.getByRole('button', { name: '每日构建' }).click() + await expect.poll(() => app.calls('update:set-channel')).toEqual([{ channel: 'beta' }, { channel: 'nightly' }]) + await expect.poll(() => channel.getByRole('button', { name: '每日构建' }).getAttribute('aria-pressed')).toBe('true') + expect(await channel.getByRole('button', { name: '测试版' }).getAttribute('aria-pressed')).toBe('false') + await app.screenshot('update-channel-nightly-light') const auto = page.getByRole('switch', { name: '自动更新' }) expect(await auto.getAttribute('aria-checked')).toBe('true') await auto.click() diff --git a/apps/desktop/src/main/install/install.ts b/apps/desktop/src/main/install/install.ts index f8d6befb..5dcfc903 100644 --- a/apps/desktop/src/main/install/install.ts +++ b/apps/desktop/src/main/install/install.ts @@ -11,7 +11,7 @@ import { spawn } from 'node:child_process' // original-fs: app folders hold app.asar, which Electron's own fs treats as a folder. import { chmodSync, copyFileSync, existsSync, mkdirSync, promises as fsp, readdirSync, renameSync, rmSync } from 'original-fs' import { basename, join } from 'node:path' -import type { InstallLocator, InstallRecord, PlatformKey, UpdateChannel } from '@shared/install' +import type { InstallLocator, InstallRecord, PlatformKey, RecordChannel } from '@shared/install' import { codedError } from '../lib/codedError' import { copyTree, listTree, treeBytes } from './copy' import { writeLinuxIntegration, windowsShortcutPaths, writeWindowsShortcuts, type WriteShortcut } from './integration' @@ -61,7 +61,7 @@ export interface InstallRequest { /** Explicit data root (macOS 高级); null = keep the existing one or the default. */ dataRoot: string | null legacy: LegacyChoice | null - channel?: UpdateChannel + channel?: RecordChannel } /** Streams a program's output lines (ditto -V); resolves with the exit code. */ diff --git a/apps/desktop/src/main/services/settings.ts b/apps/desktop/src/main/services/settings.ts index 58f9b62e..18eace35 100644 --- a/apps/desktop/src/main/services/settings.ts +++ b/apps/desktop/src/main/services/settings.ts @@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: Settings = { lanWebPort: null, lanWebInstanceId: null, updateChannel: 'stable', + nightlyUpdates: false, autoDownloadUpdates: true, skippedUpdateVersion: null, dismissedAnnouncements: [], diff --git a/apps/desktop/src/main/update/feed.ts b/apps/desktop/src/main/update/feed.ts index 319707f9..bcbf6de2 100644 --- a/apps/desktop/src/main/update/feed.ts +++ b/apps/desktop/src/main/update/feed.ts @@ -1,7 +1,7 @@ // original-fs: in the packaged main process node:fs treats every *.asar as a folder; app folders are plain files here. import { promises as fsp } from 'original-fs' import { join } from 'node:path' -import type { UpdateChannel, UpdateFeed } from '@shared/install' +import { UPDATE_CHANNELS, type UpdateChannel, type UpdateFeed } from '@shared/install' import type { UpdatePublicKey } from '@shared/updateKeys' import type { Settings } from '@shared/types' import { compareSemver } from '@shared/update' @@ -132,7 +132,7 @@ async function readHighWaterFile(dir: string): Promise { try { const raw = JSON.parse(await readFile(join(dir, HIGH_WATER_FILE), 'utf8')) as Record const result: HighWaterFile = {} - for (const channel of ['stable', 'beta'] as const) { + for (const channel of UPDATE_CHANNELS) { const entry = raw[channel] as Partial | undefined if (entry && typeof entry.version === 'string' && typeof entry.publishedAt === 'string') result[channel] = { version: entry.version, publishedAt: entry.publishedAt } diff --git a/apps/desktop/src/main/update/index.ts b/apps/desktop/src/main/update/index.ts index 9de68d68..ebfa4921 100644 --- a/apps/desktop/src/main/update/index.ts +++ b/apps/desktop/src/main/update/index.ts @@ -19,7 +19,7 @@ import { getSettings, updateSettings } from '../services/settings' import { readBusySnapshot } from './busySources' import { updateContext } from './context' import { armAppHealth, type HealthWindow } from './health' -import { isUpdateTestBuild, trustedUpdateKeys, updateFeedUrl } from './keys' +import { hasAnyUpdateKey, isUpdateTestBuild, trustedUpdateKeys, updateFeedUrl } from './keys' import { cleanExitMarkerPath, failedMarkerPath, UpdateRuntime } from './runtime' import { UpdateScheduler } from './schedule' import { loadTestHook, readyToRestart, shouldCrash, TEST_CRASH_EXIT_CODE, writeTestStatus, type UpdateTestHook } from './testHook' @@ -48,13 +48,12 @@ function writable(path: string): boolean { function getRuntime(): UpdateRuntime { if (runtime) return runtime const run = getRunContext() - const keys = trustedUpdateKeys() - const context = updateContext({ run, hasKeys: keys.length > 0, exists: existsSync, writable }) + const context = updateContext({ run, hasKeys: hasAnyUpdateKey(), exists: existsSync, writable }) const recordPath = run.recordPath runtime = new UpdateRuntime({ version: app.getVersion(), context, - keys: () => keys, + keys: (channel) => trustedUpdateKeys(channel), feedUrl: (channel) => updateFeedUrl(channel), // The CI hook always downloads automatically. settings: () => (hook()?.pollMs ? { ...getSettings(), autoDownloadUpdates: true } : getSettings()), diff --git a/apps/desktop/src/main/update/keys.ts b/apps/desktop/src/main/update/keys.ts index 50008da0..e67ed607 100644 --- a/apps/desktop/src/main/update/keys.ts +++ b/apps/desktop/src/main/update/keys.ts @@ -1,14 +1,26 @@ -import { feedUrl, type UpdateChannel } from '@shared/install' -import { PRODUCTION_UPDATE_KEYS, parseUpdateKeys, type UpdatePublicKey } from '@shared/updateKeys' +import { feedUrl, UPDATE_CHANNELS, type UpdateChannel } from '@shared/install' +import { NIGHTLY_UPDATE_KEYS, PRODUCTION_UPDATE_KEYS, parseUpdateKeys, type UpdatePublicKey } from '@shared/updateKeys' /** - * Production keys plus, in CI integration builds only, the test keys baked in at BUILD time via + * The keys a channel's feed may be signed with: the production keys, the nightly keys for the + * nightly feed only, and, in CI integration builds only, the test keys baked in at BUILD time via * the `__MOSS_UPDATE_TEST_KEYS__` define (release builds define it as []). Nothing here reads * the runtime environment: a key from the environment would let anyone sign a feed. */ -export function trustedUpdateKeys(): UpdatePublicKey[] { +export function trustedUpdateKeys( + channel: UpdateChannel, + keys: { production: readonly UpdatePublicKey[]; nightly: readonly UpdatePublicKey[] } = { + production: PRODUCTION_UPDATE_KEYS, + nightly: NIGHTLY_UPDATE_KEYS, + }, +): UpdatePublicKey[] { const test = typeof __MOSS_UPDATE_TEST_KEYS__ === 'undefined' ? [] : parseUpdateKeys(__MOSS_UPDATE_TEST_KEYS__) - return [...PRODUCTION_UPDATE_KEYS, ...test] + return [...keys.production, ...(channel === 'nightly' ? keys.nightly : []), ...test] +} + +/** True when some channel can be verified at all (otherwise updating is unsupported). */ +export function hasAnyUpdateKey(keys: (channel: UpdateChannel) => readonly UpdatePublicKey[] = trustedUpdateKeys): boolean { + return UPDATE_CHANNELS.some((channel) => keys(channel).length > 0) } /** Feed URL for a channel: feedUrl() unless the build defined `__MOSS_UPDATE_FEED_URL__` (CI). */ diff --git a/apps/desktop/src/main/update/runtime.ts b/apps/desktop/src/main/update/runtime.ts index 72cb7783..d85a6c92 100644 --- a/apps/desktop/src/main/update/runtime.ts +++ b/apps/desktop/src/main/update/runtime.ts @@ -18,7 +18,16 @@ import { fetchVerifiedFeed, loadFeedCache, loadHighWater, raiseHighWater, resetH import { setMinimumVersion, updateRequired } from './gate' import { spawnLauncherRestart, spawnMacSwap, type SpawnFn } from './handoff' import { materialize } from './materialize' -import { activeAnnouncements, channelsToRead, minimumVersionOf, selectUpdate, type UpdateSelection } from './select' +import { + activeAnnouncements, + channelSettings, + channelsToRead, + chosenChannel, + isNightlyVersion, + minimumVersionOf, + selectUpdate, + type UpdateSelection, +} from './select' import { transportUrls } from './transport' import { parseFileList } from './verify' @@ -43,6 +52,7 @@ export type UpdateSettings = Pick< | 'networkMode' | 'gitMirror' | 'updateChannel' + | 'nightlyUpdates' | 'autoDownloadUpdates' | 'dismissedAnnouncements' | 'skippedUpdateVersion' @@ -53,7 +63,8 @@ export type UpdateSettings = Pick< export interface UpdateHost { version: string context: UpdateContext - keys: () => readonly UpdatePublicKey[] + /** The keys `channel`'s feed may be signed with (trustedUpdateKeys). */ + keys: (channel: UpdateChannel) => readonly UpdatePublicKey[] feedUrl: (channel: UpdateChannel) => string settings: () => UpdateSettings saveSettings: (patch: Partial) => void @@ -140,7 +151,7 @@ export class UpdateRuntime { supported: host.context.unsupportedReason === null, unsupportedReason: host.context.unsupportedReason, current: host.version, - channel: settings.updateChannel, + channel: chosenChannel(settings), autoDownload: settings.autoDownloadUpdates, phase: host.context.unsupportedReason === null ? 'idle' : 'unsupported', checkedAt: null, @@ -223,8 +234,13 @@ export class UpdateRuntime { this.noticeUpdated() } const feeds: UpdateFeed[] = [] - for (const channel of channelsToRead(this.state.channel)) { - const cached = await loadFeedCache(this.host.cacheDir, channel, this.host.keys(), await loadHighWater(this.host.cacheDir, channel)) + for (const channel of this.verifiableChannels(this.state.channel)) { + const cached = await loadFeedCache( + this.host.cacheDir, + channel, + this.host.keys(channel), + await loadHighWater(this.host.cacheDir, channel), + ) if (cached) feeds.push(cached) } if (feeds.length) this.applyFeeds(feeds, { fromCache: true }) @@ -394,25 +410,28 @@ export class UpdateRuntime { /** Fetch and verify the feeds of the channel, select, and auto-download when enabled. */ async check(): Promise { - const channel = this.host.settings().updateChannel + const settings = this.host.settings() + const channel = chosenChannel(settings) if (channel !== this.state.channel) this.state = { ...this.state, channel } // Settings is where the channel is chosen; install.json mirrors it for the launcher and Setup. - if (this.supported && this.host.readRecord()?.channel !== channel) { - this.host.updateRecord((record) => ({ ...record, channel, updatedAt: new Date(this.now()).toISOString() })) + const recorded = settings.updateChannel + if (this.supported && this.host.readRecord()?.channel !== recorded) { + this.host.updateRecord((record) => ({ ...record, channel: recorded, updatedAt: new Date(this.now()).toISOString() })) } - if (!this.host.keys().length) { + const readable = this.verifiableChannels(channel) + if (!readable.length) { this.set({ error: '此版本未内置更新签名密钥,无法验证更新', checkedAt: new Date(this.now()).toISOString() }) return this.state } const before = this.state.phase if (!this.downloading && this.supported && before !== 'waiting-idle' && before !== 'restarting') this.set({ phase: 'checking' }) const results = await Promise.allSettled( - channelsToRead(channel).map(async (name) => { + readable.map(async (name) => { const cacheDir = this.host.cacheDir const highWater = await loadHighWater(cacheDir, name) try { const fetched = await fetchVerifiedFeed(name, { - keys: this.host.keys(), + keys: this.host.keys(name), settings: this.host.settings(), url: this.host.feedUrl(name), fetchImpl: this.host.fetchImpl, @@ -423,7 +442,7 @@ export class UpdateRuntime { return { feed: fetched.feed, error: null as unknown } } catch (error) { // A stale or replayed feed (or no network): keep the last verified one, if any. - const cached = await loadFeedCache(cacheDir, name, this.host.keys(), highWater) + const cached = await loadFeedCache(cacheDir, name, this.host.keys(name), highWater) if (cached) return { feed: cached, error } throw error } @@ -772,8 +791,9 @@ export class UpdateRuntime { } async setChannel(channel: UpdateChannel): Promise { - const before = channelsToRead(this.host.settings().updateChannel) - this.host.saveSettings({ updateChannel: channel }) + const before = channelsToRead(chosenChannel(this.host.settings())) + this.host.saveSettings(channelSettings(channel)) + if (before.includes('nightly') && !channelsToRead(channel).includes('nightly')) this.leaveNightly() // Feeds this client starts reading now are compared afresh (anti-rollback is per channel). for (const name of channelsToRead(channel)) { if (!before.includes(name)) await resetHighWater(this.host.cacheDir, name).catch(() => undefined) @@ -782,6 +802,29 @@ export class UpdateRuntime { return this.check() } + /** + * Leaving 每日构建: nothing from the nightly feed may stay selected, gate generation or install on + * the next start, even when the new channel's feeds cannot be read now. The running version stays. + */ + private leaveNightly(): void { + if (this.selection && isNightlyVersion(this.selection.version)) this.controller?.abort() + this.applyFeeds(this.feeds.filter((feed) => feed.channel !== 'nightly')) + const pending = this.supported ? this.host.readRecord()?.pending : undefined + if (pending && isNightlyVersion(pending)) { + this.host.updateRecord((record) => ({ ...record, pending: undefined, updatedAt: new Date(this.now()).toISOString() })) + this.refreshRecord() + this.host.emit(this.state) + } + } + + /** + * The feeds `channel` reads that this build can verify. A channel without a trusted key is left + * out rather than reported as failing: nightly builds can exist before the production key does. + */ + private verifiableChannels(channel: UpdateChannel): UpdateChannel[] { + return channelsToRead(channel).filter((name) => this.host.keys(name).length > 0) + } + setAutoDownload(enabled: boolean): UpdaterState { this.host.saveSettings({ autoDownloadUpdates: enabled }) this.set({ autoDownload: enabled }) diff --git a/apps/desktop/src/main/update/select.ts b/apps/desktop/src/main/update/select.ts index 5b36fd88..2436ddca 100644 --- a/apps/desktop/src/main/update/select.ts +++ b/apps/desktop/src/main/update/select.ts @@ -12,11 +12,31 @@ export interface UpdateSelection { files: FeedAsset } -/** The feeds a channel reads: beta takes the newest of beta and stable. */ +/** The feeds a channel reads: each takes the newest of itself and every steadier channel. */ export function channelsToRead(channel: UpdateChannel): UpdateChannel[] { + if (channel === 'nightly') return ['nightly', 'beta', 'stable'] return channel === 'beta' ? ['beta', 'stable'] : ['stable'] } +/** + * The channel the settings choose: 每日构建 is its own flag (see Settings.nightlyUpdates), and counts + * only with `beta`. A version from before nightlies keeps the flag on disk while saving + * `updateChannel`, so 正式版 picked there must not turn back into nightly after the next update. + */ +export function chosenChannel(settings: { updateChannel: 'stable' | 'beta'; nightlyUpdates?: boolean }): UpdateChannel { + return settings.nightlyUpdates && settings.updateChannel === 'beta' ? 'nightly' : settings.updateChannel +} + +/** `1.2.0-nightly.20261110.42`: only the nightly feed carries these. */ +export function isNightlyVersion(version: string): boolean { + return /^\d+\.\d+\.\d+-nightly(?:\.|$)/.test(version) +} + +/** What choosing a channel saves: nightly keeps `beta` for install.json and older versions. */ +export function channelSettings(channel: UpdateChannel): { updateChannel: 'stable' | 'beta'; nightlyUpdates: boolean } { + return channel === 'nightly' ? { updateChannel: 'beta', nightlyUpdates: true } : { updateChannel: channel, nightlyUpdates: false } +} + /** * Pick the update to offer. Skips versions not newer than `current` (semver with prerelease * ordering), versions in `failed`, versions up to `skipped`, and feeds without this platform. Unknown platform keys in the diff --git a/apps/desktop/src/main/update/verify.ts b/apps/desktop/src/main/update/verify.ts index 4af73418..4523cc27 100644 --- a/apps/desktop/src/main/update/verify.ts +++ b/apps/desktop/src/main/update/verify.ts @@ -76,6 +76,11 @@ export function parseVerifiedFeed( if (!feed.success) throw new Error(`更新源格式无效:${feed.error.issues[0]?.message ?? 'unknown'}`) // A validly signed feed of the other channel must not be replayed as this one. if (feed.data.channel !== channel) throw new Error(`更新源渠道不匹配(期望 ${channel},实际 ${feed.data.channel})`) + // The nightly key signs without a reviewer: it may only offer nightlies, never a version that + // would outrank every future release (9.9.9) and keep 每日构建 off the stable line for good. + if (channel === 'nightly' && !/^\d+\.\d+\.\d+-nightly(?:\.|$)/.test(feed.data.version)) { + throw new Error(`每日构建更新源里的版本不是每日构建(${feed.data.version})`) + } return feed.data } diff --git a/apps/desktop/src/renderer/src/pages/settings/UpdatesSection.tsx b/apps/desktop/src/renderer/src/pages/settings/UpdatesSection.tsx index 686d261d..f219faca 100644 --- a/apps/desktop/src/renderer/src/pages/settings/UpdatesSection.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/UpdatesSection.tsx @@ -12,6 +12,7 @@ import { SectionBody, SwitchRow, errorText, useSaveSettings } from './fields' const CHANNELS: Array<{ value: UpdateChannel; label: string }> = [ { value: 'stable', label: '正式版' }, { value: 'beta', label: '测试版' }, + { value: 'nightly', label: '每日构建' }, ] export function UpdatesSection(): React.JSX.Element { @@ -133,7 +134,7 @@ export function UpdatesSection(): React.JSX.Element { checked={update.autoDownload} onChange={(enabled) => void attempt(() => setAutoDownload(enabled))} /> - + } = {}, ): Harness { const os = options.os ?? 'linux' @@ -173,6 +180,7 @@ function harness( networkMode: 'auto', gitMirror: null, updateChannel: 'stable', + nightlyUpdates: false, autoDownloadUpdates: false, dismissedAnnouncements: [], skippedUpdateVersion: null, @@ -199,7 +207,11 @@ function harness( launcher: mac ? null : join(root, 'mossstudio'), recordPath, }, - keys: () => [key.publicKey], + keys: (channel) => + trustedUpdateKeys(channel, { + production: (options.productionKeys ?? [key]).map((item) => item.publicKey), + nightly: [nightlyKey.publicKey], + }), feedUrl: (channel) => server.url(`/update-${channel}.json`), settings: () => settings, saveSettings: (patch) => Object.assign(settings, patch), @@ -381,6 +393,67 @@ describe('check → download → materialize → pending', () => { expect(h.record().channel).toBe('beta') }) + it('每日构建 reads the nightly feed, signed by the nightly key, and picks the newest of all three', async () => { + publish('1.1.0') + publish('1.2.0-beta.1', { channel: 'beta' }) + publish('1.2.0-nightly.20261110.7', { channel: 'nightly', signedBy: nightlyKey }) + const h = harness() + const state = await h.runtime.setChannel('nightly') + expect(state).toMatchObject({ channel: 'nightly', error: null, available: { version: '1.2.0-nightly.20261110.7' } }) + // Saved so that an older version reached by rolling back reads it as 测试版, and the launcher never sees `nightly`. + expect(h.settings).toMatchObject({ updateChannel: 'beta', nightlyUpdates: true }) + expect(h.record().channel).toBe('beta') + + // Leaving it keeps the running version until a steadier channel has a newer one. + const back = await h.runtime.setChannel('stable') + expect(h.settings).toMatchObject({ updateChannel: 'stable', nightlyUpdates: false }) + expect(back).toMatchObject({ channel: 'stable', available: { version: '1.1.0' } }) + }) + + it('never trusts the nightly key for the stable or beta feed', async () => { + publish('9.0.0', { signedBy: nightlyKey }) + publish('9.0.0-beta.1', { channel: 'beta', signedBy: nightlyKey }) + publish('1.0.4-nightly.20261110.7', { channel: 'nightly', signedBy: nightlyKey }) + const h = harness({ settings: { updateChannel: 'beta', nightlyUpdates: true } }) + const state = await h.runtime.check() + expect(state.available?.version).toBe('1.0.4-nightly.20261110.7') + expect(state.error).toContain('未知的密钥') + }) + + it('on 每日构建 before any production key exists, reads only the nightly feed and reports no error', async () => { + publish('1.2.0-nightly.20261110.7', { channel: 'nightly', signedBy: nightlyKey }) + const h = harness({ productionKeys: [], settings: { updateChannel: 'beta', nightlyUpdates: true } }) + const state = await h.runtime.check() + expect(state).toMatchObject({ error: null, available: { version: '1.2.0-nightly.20261110.7' } }) + expect(server.hits.some((hit) => hit.path.startsWith('/update-stable.json'))).toBe(false) + + const stable = harness({ productionKeys: [] }) + expect((await stable.runtime.check()).error).toContain('未内置更新签名密钥') + }) + + it('leaving 每日构建 drops the nightly it offered and the one waiting to install, even when no other feed can be read', async () => { + publish('1.2.0-nightly.20261110.7', { channel: 'nightly', signedBy: nightlyKey, minimumVersion: '1.0.3' }) + const h = harness({ productionKeys: [], settings: { updateChannel: 'beta', nightlyUpdates: true } }) + await h.runtime.check() + expect((await h.runtime.download()).pending).toBe('1.2.0-nightly.20261110.7') + expect(h.record().pending).toBe('1.2.0-nightly.20261110.7') + + const state = await h.runtime.setChannel('stable') + expect(state).toMatchObject({ channel: 'stable', available: null, pending: null, minimumVersion: null }) + expect(h.record().pending).toBeUndefined() + // Nothing is left to download either. + expect((await h.runtime.download()).pending).toBeNull() + expect(h.record().pending).toBeUndefined() + }) + + it('refuses a nightly feed that offers anything but a nightly', async () => { + publish('9.9.9', { channel: 'nightly', signedBy: nightlyKey }) + const h = harness({ settings: { updateChannel: 'beta', nightlyUpdates: true } }) + const state = await h.runtime.check() + expect(state.available).toBeNull() + expect(state.error).toContain('版本不是每日构建') + }) + it('can be canceled and resumed', async () => { publish('1.1.0') const h = harness() diff --git a/apps/desktop/test/main/update/select.test.ts b/apps/desktop/test/main/update/select.test.ts index 5aa401c8..c8fdbada 100644 --- a/apps/desktop/test/main/update/select.test.ts +++ b/apps/desktop/test/main/update/select.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest' import type { FeedAsset, UpdateFeed } from '@shared/install' import { compareSemver } from '@shared/update' -import { activeAnnouncements, channelsToRead, minimumVersionOf, selectUpdate } from '@main/update/select' +import { + activeAnnouncements, + channelSettings, + channelsToRead, + chosenChannel, + isNightlyVersion, + minimumVersionOf, + selectUpdate, +} from '@main/update/select' import { feedFixture } from './helpers' const asset = (name: string, size: number): FeedAsset => ({ @@ -116,3 +124,25 @@ describe('minimumVersionOf / activeAnnouncements', () => { expect(activeAnnouncements(feeds, ['gone'], now).map((item) => item.id)).toEqual(['shared', 'future']) }) }) + +describe('channels', () => { + it('each channel reads itself and every steadier one', () => { + expect(channelsToRead('stable')).toEqual(['stable']) + expect(channelsToRead('beta')).toEqual(['beta', 'stable']) + expect(channelsToRead('nightly')).toEqual(['nightly', 'beta', 'stable']) + }) + + it('saves 每日构建 as beta plus a flag, and reads the flag only with beta', () => { + for (const channel of ['stable', 'beta', 'nightly'] as const) expect(chosenChannel(channelSettings(channel))).toBe(channel) + // A version from before nightlies saved 正式版 and kept the flag it does not know. + expect(chosenChannel({ updateChannel: 'stable', nightlyUpdates: true })).toBe('stable') + expect(chosenChannel({ updateChannel: 'beta' })).toBe('beta') + }) + + it('tells nightly versions apart', () => { + expect(isNightlyVersion('2.0.1-nightly.20261110.42')).toBe(true) + expect(isNightlyVersion('2.0.1-beta.1')).toBe(false) + expect(isNightlyVersion('2.0.1')).toBe(false) + expect(isNightlyVersion('2.0.1-nightlyish.1')).toBe(false) + }) +}) diff --git a/apps/desktop/test/main/update/verify.test.ts b/apps/desktop/test/main/update/verify.test.ts index 9cfd32b7..5ff68801 100644 --- a/apps/desktop/test/main/update/verify.test.ts +++ b/apps/desktop/test/main/update/verify.test.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { feedUrl } from '@shared/install' -import { PRODUCTION_UPDATE_KEYS, parseUpdateKeys, updateBuildDefines } from '@shared/updateKeys' -import { trustedUpdateKeys, updateFeedUrl } from '@main/update/keys' +import { NIGHTLY_UPDATE_KEYS, PRODUCTION_UPDATE_KEYS, parseUpdateKeys, updateBuildDefines } from '@shared/updateKeys' +import { hasAnyUpdateKey, trustedUpdateKeys, updateFeedUrl } from '@main/update/keys' import { FeedSignatureError, parseFileList, parseVerifiedFeed, verifyFeedSignature } from '@main/update/verify' import { feedFixture, makeKey, signFeed } from './helpers' @@ -130,12 +130,29 @@ describe('keys and build defines', () => { it('trusts only the production keys when the test-key define is absent, whatever the runtime env says', () => { process.env['MOSS_UPDATE_TEST_KEYS'] = JSON.stringify([key.publicKey]) try { - expect(trustedUpdateKeys()).toEqual([...PRODUCTION_UPDATE_KEYS]) + expect(trustedUpdateKeys('stable')).toEqual([...PRODUCTION_UPDATE_KEYS]) } finally { delete process.env['MOSS_UPDATE_TEST_KEYS'] } }) + it('trusts the nightly keys for the nightly feed only', () => { + const production = [makeKey('production').publicKey] + const nightly = [makeKey('nightly').publicKey] + expect(trustedUpdateKeys('stable', { production, nightly })).toEqual(production) + expect(trustedUpdateKeys('beta', { production, nightly })).toEqual(production) + expect(trustedUpdateKeys('nightly', { production, nightly })).toEqual([...production, ...nightly]) + // A build with only a nightly key can still update, on 每日构建. + expect(hasAnyUpdateKey((channel) => trustedUpdateKeys(channel, { production: [], nightly }))).toBe(true) + expect(hasAnyUpdateKey((channel) => trustedUpdateKeys(channel, { production: [], nightly: [] }))).toBe(false) + }) + + it('ships no key that signs both the production and the nightly feeds', () => { + const production = new Set(PRODUCTION_UPDATE_KEYS.map((item) => item.publicKey)) + expect(NIGHTLY_UPDATE_KEYS.filter((item) => production.has(item.publicKey))).toEqual([]) + expect(() => parseUpdateKeys(NIGHTLY_UPDATE_KEYS)).not.toThrow() + }) + it('resolves the feed URL, with the CI override as a template or a folder', () => { expect(updateFeedUrl('beta', '')).toBe(feedUrl('beta')) expect(updateFeedUrl('beta', 'http://127.0.0.1:9/feeds/')).toBe('http://127.0.0.1:9/feeds/update-beta.json') diff --git a/apps/desktop/test/mocks/backend/fixtures.ts b/apps/desktop/test/mocks/backend/fixtures.ts index 1052c311..c359b158 100644 --- a/apps/desktop/test/mocks/backend/fixtures.ts +++ b/apps/desktop/test/mocks/backend/fixtures.ts @@ -73,6 +73,7 @@ export function settingsFixture(patch: Partial = {}): Settings { lanWebPort: null, lanWebInstanceId: null, updateChannel: 'stable', + nightlyUpdates: false, autoDownloadUpdates: true, dismissedAnnouncements: [], noticedFailedUpdates: [], diff --git a/apps/desktop/test/mocks/backend/index.ts b/apps/desktop/test/mocks/backend/index.ts index 49e52b27..24f309cb 100644 --- a/apps/desktop/test/mocks/backend/index.ts +++ b/apps/desktop/test/mocks/backend/index.ts @@ -331,9 +331,17 @@ function defaultHandlers(): Map { if (!update.canRollback || !update.previous) return { status: 'nothing-pending' } satisfies UpdateRestartResult return restartInto(context) }) - h.set(Ipc.updateSetChannel, (payload, context) => - pushUpdate(context, { channel: p<{ channel: UpdaterState['channel'] }>(payload).channel }), - ) + h.set(Ipc.updateSetChannel, (payload, context) => { + const channel = p<{ channel: UpdaterState['channel'] }>(payload).channel + // Like main (channelSettings): 每日构建 is saved as beta plus nightlyUpdates. + context.state.settings = { + ...context.state.settings, + updateChannel: channel === 'nightly' ? 'beta' : channel, + nightlyUpdates: channel === 'nightly', + } + context.emit(Events.settingsChanged, context.state.settings) + return pushUpdate(context, { channel }) + }) h.set(Ipc.updateSetAutoDownload, (payload, context) => pushUpdate(context, { autoDownload: p<{ enabled: boolean }>(payload).enabled })) h.set(Ipc.updateUnskip, (_, context) => { context.state.settings = { ...context.state.settings, skippedUpdateVersion: null } diff --git a/apps/desktop/test/shared/schemas.test.ts b/apps/desktop/test/shared/schemas.test.ts index 8dbf7953..7b438929 100644 --- a/apps/desktop/test/shared/schemas.test.ts +++ b/apps/desktop/test/shared/schemas.test.ts @@ -370,8 +370,8 @@ const cases = { }, updateSetChannel: { - valid: { channel: 'beta' }, - rejects: { 'unknown channel': { channel: 'nightly' }, 'missing channel': {} }, + valid: { channel: 'nightly' }, + rejects: { 'unknown channel': { channel: 'canary' }, 'missing channel': {} }, }, updateToggle: { @@ -1034,6 +1034,10 @@ for (const [name, spec] of Object.entries(cases) as Array<[keyof Schemas, Schema describe('settingsUpdate field rules', () => { const parse = (input: unknown) => schemas.settingsUpdate.safeParse(input) + it('leaves 每日构建 to update:set-channel, which also resets what the new feeds are compared to', () => { + expect(parse({ nightlyUpdates: true, updateChannel: 'beta' }).data).toEqual({ updateChannel: 'beta' }) + }) + it('trims an HF endpoint and allows clearing it', () => { expect(parse({ hfEndpoint: ' https://hf-mirror.com ' }).data).toEqual({ hfEndpoint: 'https://hf-mirror.com' }) expect(parse({ hfEndpoint: '' }).data).toEqual({ hfEndpoint: '' }) diff --git a/apps/desktop/test/tools/release.test.mjs b/apps/desktop/test/tools/release.test.mjs index d8695b73..ce108f12 100644 --- a/apps/desktop/test/tools/release.test.mjs +++ b/apps/desktop/test/tools/release.test.mjs @@ -28,6 +28,8 @@ import { currentAnnouncements, describePlatforms, feedBytes, + nightlyNotes, + nightlySkipReason, parseNotes, } from '../../../../tools/release/lib/feed.mjs' import { generateKeyPair, keyIdFor, parsePrivateKey, publicKeyBase64, signFeed, verifyFeed } from '../../../../tools/release/lib/sign.mjs' @@ -36,10 +38,14 @@ import { bumpPatch, compareVersions, deltaName, + isNightly, isPrerelease, + nightlyAssetUrl, + nightlyVersion, parseDeltaName, portableMarker, releaseAssetUrl, + staleNightlyPackages, } from '../../../../tools/release/lib/version.mjs' import { asarPackageJson, readSetupTar, readTar, TRAILER_SIZE } from '../../../../tools/release/lib/inspect.mjs' import { @@ -465,6 +471,109 @@ describe('feeds and signatures', () => { }) }) +describe('nightlies', () => { + const MAKE_FEED = join(__dirname, '..', '..', '..', '..', 'tools', 'release', 'make-feed.mjs') + + it('versions a nightly below the release it leads up to, ordered by day and run', () => { + const night = new Date('2026-11-10T18:00:00Z') + expect(nightlyVersion('2.0.0', ['v1.0.2'], night, 42)).toBe('2.0.0-nightly.20261110.42') + // 2.0.0 already shipped: the next release is 2.0.1. + expect(nightlyVersion('2.0.0', ['v2.0.0'], night, 42)).toBe('2.0.1-nightly.20261110.42') + expect(compareVersions('2.0.1-nightly.20261110.42', '2.0.1')).toBeLessThan(0) + expect(compareVersions('2.0.1-nightly.20261110.42', '2.0.0')).toBeGreaterThan(0) + // A manual run later the same day comes after the scheduled one; the next day after both. + expect(compareVersions('2.0.1-nightly.20261110.43', '2.0.1-nightly.20261110.42')).toBeGreaterThan(0) + expect(compareVersions('2.0.1-nightly.20261111.1', '2.0.1-nightly.20261110.43')).toBeGreaterThan(0) + expect(isNightly('2.0.1-nightly.20261110.42')).toBe(true) + expect(isNightly('2.0.1-beta.1')).toBe(false) + expect(() => nightlyVersion('2.0.0', [], night, 0)).toThrow(/run number/) + }) + + it('prunes only the update packages of nightlies before the last two, never a feed or a release package', () => { + const now = '2.0.1-nightly.20261110.42' + const last = '2.0.1-nightly.20261109.41' + const old = '2.0.1-nightly.20261108.40' + const names = [ + 'update-nightly.json', + 'update-nightly.json.sig', + 'update-stable.json', + 'update-beta.json.sig', + artifactNames(now, 'linux-x64').full, + artifactNames(last, 'win32-x64').full, + artifactNames(old, 'win32-x64').full, + artifactNames(old, 'darwin-arm64').files, + artifactNames('2.0.0', 'linux-x64').full, + deltaName(old, last, 'linux-x64'), + deltaName('2.0.0', old, 'linux-x64'), + deltaName(last, now, 'linux-x64'), + ] + expect(staleNightlyPackages(names, [now, last])).toEqual([ + artifactNames(old, 'win32-x64').full, + artifactNames(old, 'darwin-arm64').files, + deltaName('2.0.0', old, 'linux-x64'), + ]) + // The first nightly with a feed has no previous one to keep. + expect(staleNightlyPackages(names, [now, ''])).toContain(artifactNames(last, 'win32-x64').full) + }) + + it('replaces the nightly feed only with a newer nightly', () => { + expect(nightlySkipReason('2.0.1-nightly.20261110.42')).toBeNull() + expect(nightlySkipReason('2.0.1-nightly.20261110.42', '2.0.1-nightly.20261109.41')).toBeNull() + expect(nightlySkipReason('2.0.1-nightly.20261110.42', '2.0.1-nightly.20261110.42')).toMatch(/keeps/) + expect(nightlySkipReason('2.0.1-nightly.20261109.40', '2.0.1-nightly.20261110.42')).toMatch(/keeps/) + expect(nightlySkipReason('2.0.1', undefined)).toMatch(/not a nightly/) + }) + + it('writes notes from the commit subjects, with the rest counted', () => { + const sha = 'abcdef1234567890' + expect(nightlyNotes({ sha, repo: 'o/r', subjects: [] })).toBe( + '每日构建,来自 `main` 的 [`abcdef1`](https://github.com/o/r/commit/abcdef1234567890)。', + ) + const notes = nightlyNotes({ sha, repo: 'o/r', subjects: ['feat: a', 'fix: b', 'docs: c'], limit: 2 }) + expect(notes.split('\n').slice(1)).toEqual(['', '- feat: a', '- fix: b', '- 以及另外 1 项改动']) + }) + + it('make-feed --nightly writes update-nightly.json on the nightly release, which only a nightly key verifies', () => { + const version = '2.0.1-nightly.20261110.42' + const dir = join(temp(), 'artifacts') + mkdirSync(dir) + for (const [kind, name] of Object.entries(artifactNames(version, 'linux-x64'))) writeFileSync(join(dir, name), `${kind} bytes`) + writeFileSync(join(dir, deltaName('2.0.1-nightly.20261109.41', version, 'linux-x64')), 'delta') + const notes = join(temp(), 'notes.md') + writeFileSync(notes, nightlyNotes({ sha: 'a'.repeat(40), repo: 'o/r', subjects: ['feat: x'] })) + const make = (...args) => + spawnSync(process.execPath, [MAKE_FEED, '--nightly', '--version', version, '--artifacts', dir, '--notes-file', notes, ...args], { + encoding: 'utf8', + }) + + const current = join(temp(), 'current-update-nightly.json') + writeFileSync(current, JSON.stringify({ version: '2.0.1-nightly.20261110.42' })) + const skipped = make('--current-nightly', current) + expect(skipped.status, skipped.stderr).toBe(0) + expect(existsSync(join(dir, 'update-nightly.json'))).toBe(false) + + writeFileSync(current, JSON.stringify({ version: '2.0.1-nightly.20261109.41' })) + const written = make('--current-nightly', current) + expect(written.status, written.stderr).toBe(0) + expect(['update-stable.json', 'update-beta.json'].filter((name) => existsSync(join(dir, name)))).toEqual([]) + const path = join(dir, 'update-nightly.json') + const feed = updateFeedSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + expect(feed).toMatchObject({ channel: 'nightly', version, notesUrl: 'https://github.com/ShallowElec/MossStudio/releases/tag/nightly' }) + expect(feed.notes).toContain('- feat: x') + expect(feed.platforms['linux-x64'].full.url).toBe(nightlyAssetUrl(artifactNames(version, 'linux-x64').full)) + expect(feed.platforms['linux-x64'].deltas.map((delta) => delta.from)).toEqual(['2.0.1-nightly.20261109.41']) + + const nightly = generateKeyPair() + writeFileSync(`${path}.sig`, JSON.stringify(signFeed(readFileSync(path), parsePrivateKey(nightly.privateKeyBase64)))) + const trusted = [{ id: nightly.id, publicKey: nightly.publicKey }] + const opts = { dir, trusted, updateFeedSchema, feedSignatureSchema, expectVersion: version, urlPrefix: nightlyAssetUrl('') } + expect(checkFeed(path, opts).channel).toBe('nightly') + expect(parseVerifiedFeed(readFileSync(path), readFileSync(`${path}.sig`), trusted, 'nightly').version).toBe(version) + // Replayed as another channel's feed, the app refuses it. + expect(() => parseVerifiedFeed(readFileSync(path), readFileSync(`${path}.sig`), trusted, 'beta')).toThrow(/渠道不匹配/) + }) +}) + describe('setup files and portable packages', () => { function tarOf(dir, entries) { const tar = spawnSync('tar', ['--owner=0', '--group=0', '--numeric-owner', '-cf', '-', '-C', dir, ...entries], { maxBuffer: 1 << 28 }) diff --git a/docs/README.md b/docs/README.md index 3389fcc9..2593a522 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,7 +48,7 @@ Decisions are ADRs in [decisions/](decisions/README.md), one per choice, never e | --- | --- | | MossInfer is the only inference engine | [0009](decisions/0009-mossinfer-inference.md), amended by [0012](decisions/0012-vulkan-and-hip-cli-engines.md) (Vulkan and HIP) | | Rail navigation and the node board | [0011](decisions/0011-rail-navigation-and-node-board.md), which supersedes [0008](decisions/0008-studio-window-and-dock.md) | -| Own installer, launcher and signed updates | [0010](decisions/0010-own-installer-and-signed-updates.md) | +| Own installer, launcher and signed updates | [0010](decisions/0010-own-installer-and-signed-updates.md), amended by [0014](decisions/0014-nightly-update-channel.md) (nightly channel) | | The Trainer stays an external Python tool | [0002](decisions/0002-trainer-backend.md) | | How these docs are organised | [0013](decisions/0013-documentation-structure-and-language.md) | diff --git a/docs/decisions/0010-own-installer-and-signed-updates.md b/docs/decisions/0010-own-installer-and-signed-updates.md index 1cbc3981..befcc987 100644 --- a/docs/decisions/0010-own-installer-and-signed-updates.md +++ b/docs/decisions/0010-own-installer-and-signed-updates.md @@ -2,6 +2,7 @@ status: accepted date: 2026-11-06 supersedes: ADR-0005 +amended-by: ADR-0014 --- # ADR-0010: Own installer, launcher and signed update feed diff --git a/docs/decisions/0014-nightly-update-channel.md b/docs/decisions/0014-nightly-update-channel.md new file mode 100644 index 00000000..b5ac6bd1 --- /dev/null +++ b/docs/decisions/0014-nightly-update-channel.md @@ -0,0 +1,46 @@ +--- +status: proposed +date: 2026-11-10 +amends: ADR-0010 +--- + +# ADR-0014: A nightly update channel signed by its own key + +## Context and problem statement + +`.github/workflows/nightly.yml` builds `main` every night, but a nightly is download-only: no feed names it, so someone who wants to follow `main` has to download and install a new setup by hand each day. ADR-0010 keeps the only signing key in the protected `release` environment, which requires a reviewer and accepts only `v*` tags, so an unattended nightly run cannot sign a feed with it. The maintainer asked for a nightly channel that updates by itself. How can nightlies be signed without weakening what stable and beta clients trust? + +## Decision drivers + +- A leaked or misused nightly signature must never reach people on 正式版 or 测试版. +- Nightlies run unattended at 18:00 UTC; nobody approves them. +- Launchers already installed are not replaced by updates and accept only `stable` and `beta` in `install.json`. +- Someone who rolls back from a nightly to a version from before this change must keep updating. + +## Considered options + +1. Sign nightlies with the production key and approve the `release` environment every night. +2. A second key, held by a `nightly` environment without a reviewer and limited to `main`, that clients trust for `update-nightly.json` only. +3. Keep nightlies download-only. + +## Decision outcome + +Chosen: option 2, as the maintainer chose on 2026-11-10. Option 1 means a manual approval every night, and one key for every channel. Option 3 is what the maintainer asked to change. + +1. **Channel.** `UPDATE_CHANNELS` gains `nightly`. A client on 每日构建 reads the nightly, beta and stable feeds and takes the newest version (`channelsToRead` in `main/update/select.ts`). +2. **Key scope.** `NIGHTLY_UPDATE_KEYS` in `shared/updateKeys.ts` is trusted for the nightly feed only (`trustedUpdateKeys(channel)` in `main/update/keys.ts`); the signed feed also names its channel, so it cannot be replayed as another one. The private key is the secret `MOSS_NIGHTLY_SIGNING_KEY` of the `nightly` environment. The production key never enters the nightly workflow. +3. **Settings, not install.json.** `install.json` keeps `stable` or `beta` (`RECORD_CHANNELS`), so older launchers still read it. 每日构建 is saved as `updateChannel: 'beta'` plus `nightlyUpdates: true`; a version from before this change reads that as 测试版. +4. **Packages.** The nightly's full packages, file lists and deltas from the previous nightly go on the rolling `updates` release beside the feeds, which is never deleted. The downloadable `nightly` release is replaced as before. +5. **Leaving.** Switching back to 正式版 or 测试版 keeps the running nightly until that channel has a newer version; there is no downgrade. + +### Consequences + +- Good: people who want the newest build get it every morning without reinstalling, with deltas between consecutive nightlies. +- Good: the production key's reach is unchanged. A compromised nightly key can reach only clients on 每日构建. +- Bad: a second secret to keep and rotate. Until its public half ships in a release, nightlies stay download-only. +- Bad: a compromised nightly key can still ship anything to everyone on 每日构建, including a `minimumVersion` that blocks generation. That is the cost of signing without a reviewer. + +## More information + +- Amends [ADR-0010](0010-own-installer-and-signed-updates.md), point 4: the production key stays in `release`; the nightly key lives in `nightly`. +- Setup and rotation: [docs/how-to/release.md](../how-to/release.md#nightly-builds). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1bda2ce7..27713c2f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,7 +15,8 @@ ADRs 0001 to 0009 are written in Chinese. This index gives each an English title | [0007](0007-mossstudio-single-ui-and-web.md) | MossStudio as the only end-user UI; the official Gradio retires | accepted, amended by ADR-0011 | One product UI, a LAN web page served by the app's backend, and Gradio kept only as an upstream reference. 0011 withdraws the shared feature layer it planned for the web UI. | | [0008](0008-studio-window-and-dock.md) | One Studio window, workbench overlay and dockable panels | superseded by ADR-0011 | Two windows (`studio`, `trainer`), bottom page tabs, a three-column Studio with dockable panels and canvas tabs. The two windows stand; the rest was replaced by the rail and the board. | | [0009](0009-mossinfer-inference.md) | MossInfer as the only inference engine | accepted, amended by ADR-0012 | Inference runs through the `moss-server` sidecar on NVIDIA GPUs, without Python or Instances; the same protocol on Linux SSH hosts. 0012 adds Vulkan and HIP engines for other GPUs. | -| [0010](0010-own-installer-and-signed-updates.md) | Own installer, launcher and signed update feed | accepted | The app installs and updates itself on Windows, Linux and macOS: a Go launcher switches versions and rolls back, updates come from an Ed25519-signed feed, and the signing key lives only in the `release` environment (PR #27). | +| [0010](0010-own-installer-and-signed-updates.md) | Own installer, launcher and signed update feed | accepted, amended by ADR-0014 | The app installs and updates itself on Windows, Linux and macOS: a Go launcher switches versions and rolls back, updates come from an Ed25519-signed feed, and the signing key lives only in the `release` environment (PR #27). | | [0011](0011-rail-navigation-and-node-board.md) | Rail navigation and the node board replace the dock layout | proposed | A left rail with 创作, 作品, 模型 and optional 训练, one Activity panel, a full-page setup, and one free-form board per project; the LAN web UI stays a separate small app. | | [0012](0012-vulkan-and-hip-cli-engines.md) | Vulkan and HIP CLI engines beside the CUDA server | proposed | `moss-infer-vulkan` (兼容模式) and `moss-infer-hip` (AMD 原生) run text-to-image jobs one process at a time on GPUs without CUDA; the Studio hides what they cannot do. | | [0013](0013-documentation-structure-and-language.md) | Documentation structure and language | proposed | Diátaxis folders plus `decisions/` and `records/`, English developer docs and Chinese user text, style guides by reference, MADR 4.0 with front-matter status, a header on every living doc, and `AGENTS.md` as the agent entry point. | +| [0014](0014-nightly-update-channel.md) | A nightly update channel signed by its own key | proposed | 每日构建 follows a signed `update-nightly.json`. Its key sits in a `nightly` environment without a reviewer and is trusted for that feed only; `install.json` keeps `stable` or `beta` for older launchers. | diff --git a/docs/how-to/release.md b/docs/how-to/release.md index b92d3491..4f09745b 100644 --- a/docs/how-to/release.md +++ b/docs/how-to/release.md @@ -26,11 +26,29 @@ Test builds from `build.yml` trust an ephemeral key and read a local feed. They ## Nightly builds -`.github/workflows/nightly.yml` builds `main` every night at 18:00 UTC. It replaces the rolling `nightly` prerelease, moving its tag to the new commit, and uploads the setups and portable packages for all four platforms. +`.github/workflows/nightly.yml` builds `main` every night at 18:00 UTC. It replaces the rolling `nightly` prerelease, moving its tag to the new commit, and uploads the setups and portable packages for all four platforms. With a nightly key it also updates everyone on 每日构建 ([ADR-0014](../decisions/0014-nightly-update-channel.md)). -- The version is `-nightly.`, for example `1.0.3-nightly.20261108`. The next release is the `package.json` version, or one patch later when that version is already tagged. So a nightly always ranks below the release that follows it. -- A nightly is download-only. It has no full or delta packages, it is on no update feed, and it never enters the `release` environment or touches the signing key. +- The version is `-nightly..`, for example `1.0.3-nightly.20261108.42` (`nightlyVersion` in `tools/release/lib/version.mjs`). The next release is the `package.json` version, or one patch later when that version is already tagged, so a nightly always ranks below the release that follows it. The run number orders two builds of one day. - The scheduled run skips a night when `main` is still at the `nightly` tag. To build one now, run `gh workflow run nightly.yml`. +- The job `publish` runs in the `nightly` environment. When it holds `MOSS_NIGHTLY_SIGNING_KEY`, the job: + 1. fetches the last nightly's file lists and its verified `update-nightly.json` (`fetch-nightly.mjs`); + 2. builds deltas from the last nightly; + 3. writes `update-nightly.json` (`make-feed.mjs --nightly`), but only when it is newer than the published one; + 4. signs it and checks it against `NIGHTLY_UPDATE_KEYS`; + 5. uploads the full packages, file lists and deltas to the `updates` release, then the `.sig`, then the feed, and only then deletes the packages of nightlies older than the previous one (`staleNightlyPackages`), so a download that started from the old feed can finish. +- Without the secret, a nightly is download-only, as before: no feed and no update packages. +- The nightly workflow never enters the `release` environment and never sees the production key. + +### Setting up the nightly key (a maintainer, once) + +1. Run `pnpm release:keygen --nightly`. It prints a `NIGHTLY_UPDATE_KEYS` entry and the private key. +2. Add the entry to `NIGHTLY_UPDATE_KEYS` in `apps/desktop/src/shared/updateKeys.ts` in a PR and merge it. Clients trust the key once they run a version that has it, so the first nightly after the merge is the first that 每日构建 can update from. +3. In GitHub, **Settings › Environments › `nightly`**: + - add the secret `MOSS_NIGHTLY_SIGNING_KEY` (the private key); + - limit deployment to the `main` branch; + - no reviewer: clients accept this key for `update-nightly.json` only. + +To rotate it, follow [Rotating the signing key](#rotating-the-signing-key) with `NIGHTLY_UPDATE_KEYS` and nightlies instead of releases. Remove the old public key only after a nightly signed with the new key has been published: `fetch-nightly.mjs` checks the published feed against `NIGHTLY_UPDATE_KEYS` and stops the run when no key verifies it. ## One-time setup (a maintainer) @@ -78,3 +96,4 @@ To run the whole install → update → rollback → uninstall path locally befo - The binaries are not code-signed (Windows Authenticode, macOS notarization). Users see the system's first-run warning. - `PRODUCTION_UPDATE_KEYS` is empty until the one-time setup above is done. +- `NIGHTLY_UPDATE_KEYS` is empty until the nightly key is set up; nightlies are download-only until then. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index d2d367a9..08ddcf1d 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -147,7 +147,7 @@ The LAN page (`renderer/src/web/main.tsx` with `web/LayoutEditor.tsx`) is a sepa [ADR-0010](/docs/decisions/0010-own-installer-and-signed-updates.md) made the app its own installer and updater. An install root R holds the launcher, one `app-/` folder per version, `data/` and `install.json` (`installRecordSchema` in `shared/install.ts`: `root`, `dataRoot`, `portable`, `channel`, `current`, `previous`, `pending`, `failed`, `confirmed`); on macOS R holds `MossStudio.app` and hidden previous bundles. `main/install/` is the setup state machine (`controller.ts`), its Electron glue and windows (`app.ts`), the layout rules per OS (`layout.ts`), the record IO (`record.ts`), the registry on Windows (`registry.ts`) and the uninstall (`uninstall.ts`). The setup and uninstall UI is `pages/install/` with the types in `shared/setup.ts`. -`main/update/` is the updater: it reads the Ed25519-signed feed `update-.json` (`feed.ts`, `verify.ts`; keys in `shared/updateKeys.ts`), checks on start, every six hours and on window focus (`schedule.ts`), downloads with resume (`download.ts`), materialises `app-` (`materialize.ts`), writes `pending` into `install.json`, and restarts through the launcher when nothing is busy (`busy.ts`: inference, training, model and engine downloads, git clones). The launcher switches `pending` to `current` and rolls back when the new version fails its health check (`health.ts`); on macOS, which has no launcher, `installPendingAtStart` runs the same swap helper at the next start, from a confirmed version only. The UI is `components/update/` and 设置 › 通知与更新; the state type is `UpdaterState` in `shared/update.ts`. How releases are built and signed is in [how-to/release.md](/docs/how-to/release.md). +`main/update/` is the updater: it reads the Ed25519-signed feed `update-.json` (`feed.ts`, `verify.ts`; keys in `shared/updateKeys.ts`, the nightly keys trusted for `update-nightly.json` only by `trustedUpdateKeys` in `keys.ts`; a channel reads its own feed and every steadier one, `channelsToRead` in `select.ts`), checks on start, every six hours and on window focus (`schedule.ts`), downloads with resume (`download.ts`), materialises `app-` (`materialize.ts`), writes `pending` into `install.json`, and restarts through the launcher when nothing is busy (`busy.ts`: inference, training, model and engine downloads, git clones). The launcher switches `pending` to `current` and rolls back when the new version fails its health check (`health.ts`); on macOS, which has no launcher, `installPendingAtStart` runs the same swap helper at the next start, from a confirmed version only. The channel is chosen in settings and mirrored into `install.json` as `stable` or `beta` only, because launchers already installed accept nothing else: 每日构建 is `updateChannel: 'beta'` plus `nightlyUpdates` ([ADR-0014](/docs/decisions/0014-nightly-update-channel.md)). The UI is `components/update/` and 设置 › 通知与更新; the state type is `UpdaterState` in `shared/update.ts`. How releases are built and signed is in [how-to/release.md](/docs/how-to/release.md). Updates are quiet by default. With 自动更新 on (`Settings.autoDownloadUpdates`), a new version downloads in the background and installs at the next start without a word; the title-bar chip (`components/update/updateModel.ts`, `chipView`) shows only 必须更新 (this version may no longer generate, `minimumVersion`), 新版本没能启动 (a rolled-back version), a restart the user asked for, and a quiet 重启以更新 once a downloaded update has waited three days (`READY_NUDGE_MS`, from `UpdaterState.readyAt`). The first start of a newer version says 已更新到 once, with a link to its release notes (`UpdaterState.updatedFrom`, from `Settings.lastRunVersion`). With 自动更新 off, every step shows, since the user installs by hand. diff --git a/tools/release/fetch-nightly.mjs b/tools/release/fetch-nightly.mjs new file mode 100644 index 00000000..3e231842 --- /dev/null +++ b/tools/release/fetch-nightly.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Download what a nightly's publish job needs from the last nightly, all on the `updates` release: +// - its file lists files--.json, into --out, so make-deltas.mjs builds deltas +// from the nightly clients most likely run +// - update-nightly.json, signature-checked against NIGHTLY_UPDATE_KEYS, as +// --out/current-update-nightly.json (make-feed --current-nightly) +// Without an earlier nightly's packages there is no delta. +// +// GITHUB_TOKEN=... node tools/release/fetch-nightly.mjs --out [--repo owner/name] +import { mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { main } from './lib/cli.mjs' +import { loadInstallContract, loadNightlyKeys } from './lib/contract.mjs' +import { verifiedPublishedFeed } from './lib/verify.mjs' +import { isNightly, isVersion, RELEASE_REPO, UPDATES_TAG } from './lib/version.mjs' + +function headers(accept) { + const out = { 'User-Agent': 'mossstudio-release', Accept: accept } + if (process.env.GITHUB_TOKEN) out.Authorization = `Bearer ${process.env.GITHUB_TOKEN}` + return out +} + +async function release(repo, tag) { + const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${tag}`, { + headers: headers('application/vnd.github+json'), + signal: AbortSignal.timeout(30_000), + }) + if (response.status === 404) return null + if (!response.ok) throw new Error(`release ${tag}: ${response.status} ${await response.text()}`) + return response.json() +} + +async function download(asset) { + const response = await fetch(asset.url, { + headers: headers('application/octet-stream'), + redirect: 'follow', + signal: AbortSignal.timeout(120_000), + }) + if (!response.ok) throw new Error(`download ${asset.name}: ${response.status}`) + return Buffer.from(await response.arrayBuffer()) +} + +main(async () => { + const { values } = parseArgs({ options: { out: { type: 'string' }, repo: { type: 'string', default: RELEASE_REPO } } }) + if (!values.out) throw new Error('--out is required') + const out = resolve(values.out) + mkdirSync(out, { recursive: true }) + + const updates = await release(values.repo, UPDATES_TAG) + const lists = (updates?.assets ?? []).filter((asset) => { + const version = /^files-(.+)-(win32-x64|linux-x64|darwin-arm64|darwin-x64)\.json$/.exec(asset.name)?.[1] + return Boolean(version && isVersion(version) && isNightly(version)) + }) + if (lists.length === 0) console.log('no nightly file lists on the updates release: no deltas') + for (const asset of lists) { + writeFileSync(join(out, asset.name), await download(asset)) + console.log(` ${asset.name}`) + } + + const trusted = await loadNightlyKeys() + if (trusted.length === 0) { + console.log('NIGHTLY_UPDATE_KEYS is empty: no published nightly feed can be trusted') + return + } + const feedAsset = updates?.assets.find((item) => item.name === 'update-nightly.json') + if (!feedAsset) { + console.log(' no published update-nightly.json') + return + } + const sigAsset = updates.assets.find((item) => item.name === 'update-nightly.json.sig') + const bytes = await download(feedAsset) + const contract = await loadInstallContract() + const feed = verifiedPublishedFeed(bytes, sigAsset ? await download(sigAsset) : null, { + channel: 'nightly', + trusted, + updateFeedSchema: contract.updateFeedSchema, + feedSignatureSchema: contract.feedSignatureSchema, + }) + writeFileSync(join(out, 'current-update-nightly.json'), bytes) + console.log(` current update-nightly.json: ${feed.version} (signature verified)`) +}) diff --git a/tools/release/keygen.mjs b/tools/release/keygen.mjs index d2ac665f..3f38bb9d 100644 --- a/tools/release/keygen.mjs +++ b/tools/release/keygen.mjs @@ -1,28 +1,36 @@ #!/usr/bin/env node // Generate an Ed25519 key pair for signing update feeds. // -// pnpm release:keygen human-readable: the updateKeys.ts entry and the secret +// pnpm release:keygen human-readable: the updateKeys.ts entry and the secret +// pnpm release:keygen --nightly the same for the nightly key (update-nightly.json only) // node tools/release/keygen.mjs --json one JSON line { id, publicKey, privateKey } (CI test keys) import { parseArgs } from 'node:util' import { main } from './lib/cli.mjs' import { generateKeyPair } from './lib/sign.mjs' main(async () => { - const { values } = parseArgs({ options: { json: { type: 'boolean', default: false } } }) + const { values } = parseArgs({ options: { json: { type: 'boolean', default: false }, nightly: { type: 'boolean', default: false } } }) const pair = generateKeyPair() if (values.json) { console.log(JSON.stringify({ id: pair.id, publicKey: pair.publicKey, privateKey: pair.privateKeyBase64 })) return } - console.log('Public key: add this entry to PRODUCTION_UPDATE_KEYS in apps/desktop/src/shared/updateKeys.ts') + const list = values.nightly ? 'NIGHTLY_UPDATE_KEYS' : 'PRODUCTION_UPDATE_KEYS' + const secret = values.nightly ? 'MOSS_NIGHTLY_SIGNING_KEY' : 'MOSS_UPDATE_SIGNING_KEY' + const environment = values.nightly ? 'nightly' : 'release' + console.log(`Public key: add this entry to ${list} in apps/desktop/src/shared/updateKeys.ts`) console.log('') console.log(` { id: '${pair.id}', publicKey: '${pair.publicKey}' },`) console.log('') - console.error('WARNING: the private key below signs updates for every MossStudio install.') - console.error('Store it only as the GitHub Actions secret MOSS_UPDATE_SIGNING_KEY (Settings › Secrets and') - console.error('variables › Actions). Never commit it, paste it into an issue or keep it in shell history.') + console.error( + values.nightly + ? 'WARNING: the private key below signs updates for every MossStudio install on 每日构建.' + : 'WARNING: the private key below signs updates for every MossStudio install.', + ) + console.error(`Store it only as the secret ${secret} of the GitHub environment \`${environment}\` (Settings ›`) + console.error('Environments). Never commit it, paste it into an issue or keep it in shell history.') console.log('') - console.log('Private key (MOSS_UPDATE_SIGNING_KEY, base64 Ed25519 seed):') + console.log(`Private key (${secret}, base64 Ed25519 seed):`) console.log('') console.log(pair.privateKeyBase64) }) diff --git a/tools/release/lib/contract.mjs b/tools/release/lib/contract.mjs index 9138a5b4..c367a9a6 100644 --- a/tools/release/lib/contract.mjs +++ b/tools/release/lib/contract.mjs @@ -20,3 +20,9 @@ export async function loadProductionKeys() { const module = await load('updateKeys.ts') return [...module.PRODUCTION_UPDATE_KEYS] } + +/** The keys clients trust for update-nightly.json only. */ +export async function loadNightlyKeys() { + const module = await load('updateKeys.ts') + return [...module.NIGHTLY_UPDATE_KEYS] +} diff --git a/tools/release/lib/feed.mjs b/tools/release/lib/feed.mjs index e57687b1..e1092ad4 100644 --- a/tools/release/lib/feed.mjs +++ b/tools/release/lib/feed.mjs @@ -1,7 +1,7 @@ // Feed assembly (shared/install.ts updateFeedSchema) from built artifacts. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' -import { artifactNames, compareVersions, isPrerelease, isVersion, parseDeltaName, PLATFORMS } from './version.mjs' +import { artifactNames, compareVersions, isPrerelease, isVersion, parseDeltaName, parseVersion, PLATFORMS } from './version.mjs' import { sha256File } from './tree.mjs' /** @@ -23,6 +23,26 @@ export function channelsFor(version, { currentStable, currentBeta } = {}) { return { channels, skipped } } +/** + * Whether a nightly may replace the published update-nightly.json: only with a newer version, so + * re-running an old build never sends nightly clients backwards. Returns null when it may, else why not. + */ +export function nightlySkipReason(version, currentNightly) { + if (!isVersion(version) || parseVersion(version).pre[0] !== 'nightly') return `${version} is not a nightly version` + if (currentNightly && compareVersions(currentNightly, version) >= 0) return `update-nightly.json keeps ${currentNightly} (not older than ${version})` + return null +} + +/** The in-app notes of a nightly: where it comes from and the commit subjects since the last one. */ +export function nightlyNotes({ sha, repo, subjects, limit = 40 }) { + const lines = [`每日构建,来自 \`main\` 的 [\`${sha.slice(0, 7)}\`](https://github.com/${repo}/commit/${sha})。`] + if (subjects.length) { + lines.push('', ...subjects.slice(0, limit).map((subject) => `- ${subject}`)) + if (subjects.length > limit) lines.push(`- 以及另外 ${subjects.length - limit} 项改动`) + } + return lines.join('\n') +} + /** * Split release notes into front matter and body. Front matter is optional: * --- diff --git a/tools/release/lib/verify.mjs b/tools/release/lib/verify.mjs index a5b2e121..27fb3ad3 100644 --- a/tools/release/lib/verify.mjs +++ b/tools/release/lib/verify.mjs @@ -166,7 +166,7 @@ export function checkFeed(feedPath, { dir, trusted, updateFeedSchema, feedSignat const sig = feedSignatureSchema.parse(JSON.parse(readFileSync(`${feedPath}.sig`, 'utf8'))) if (!verifyFeed(bytes, sig, trusted)) throw new Error(`${label}: signature (keyId ${sig.keyId}) does not verify against the trusted keys`) const feed = updateFeedSchema.parse(JSON.parse(bytes.toString('utf8'))) - const channel = /^update-(stable|beta)\.json$/.exec(label)?.[1] + const channel = /^update-(stable|beta|nightly)\.json$/.exec(label)?.[1] if (feed.channel !== channel) throw new Error(`${label}: channel is ${feed.channel}`) if (expectVersion && feed.version !== expectVersion) throw new Error(`${label}: version ${feed.version}, expected ${expectVersion}`) const assets = [] @@ -195,7 +195,7 @@ export function checkFeed(feedPath, { dir, trusted, updateFeedSchema, feedSignat export function verifiedPublishedFeed(feedBytes, sigBytes, { channel, trusted, updateFeedSchema, feedSignatureSchema }) { if (!sigBytes) throw new Error(`published update-${channel}.json has no .sig`) const sig = feedSignatureSchema.parse(JSON.parse(Buffer.from(sigBytes).toString('utf8'))) - if (!verifyFeed(feedBytes, sig, trusted)) throw new Error(`published update-${channel}.json does not verify against the production keys (keyId ${sig.keyId})`) + if (!verifyFeed(feedBytes, sig, trusted)) throw new Error(`published update-${channel}.json does not verify against the trusted keys (keyId ${sig.keyId})`) const feed = updateFeedSchema.parse(JSON.parse(Buffer.from(feedBytes).toString('utf8'))) if (feed.channel !== channel) throw new Error(`published update-${channel}.json says channel ${feed.channel}`) return feed diff --git a/tools/release/lib/version.mjs b/tools/release/lib/version.mjs index d6410000..facac9ac 100644 --- a/tools/release/lib/version.mjs +++ b/tools/release/lib/version.mjs @@ -92,6 +92,61 @@ export function releaseAssetUrl(version, file, repo = RELEASE_REPO) { return `https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(file)}` } +/** The rolling prerelease people download the latest nightly from (setups and portable packages). */ +export const NIGHTLY_TAG = 'nightly' + +/** The rolling release that holds the feeds, and the latest nightly's update packages next to them. */ +export const UPDATES_TAG = 'updates' + +/** + * Where update-nightly.json points: the `updates` release, which is never deleted, so the feed never + * names a file that is gone while `nightly` is replaced. File names carry their version. + */ +export function nightlyAssetUrl(file, repo = RELEASE_REPO) { + return `https://github.com/${repo}/releases/download/${UPDATES_TAG}/${encodeURIComponent(file)}` +} + +/** + * A nightly's version: `-nightly..`, e.g. 1.0.3-nightly.20261108.42. + * The next release is `packageVersion`'s x.y.z, or one patch later when that is already tagged, so + * the release that follows ranks above every nightly before it. The workflow run number orders two + * builds of one day (a manual run after the scheduled one). + */ +export function nightlyVersion(packageVersion, tags, date, run) { + const v = parseVersion(packageVersion) + let core = `${v.major}.${v.minor}.${v.patch}` + if (tags.includes(core) || tags.includes(`v${core}`)) core = `${v.major}.${v.minor}.${v.patch + 1}` + const day = date.toISOString().slice(0, 10).replaceAll('-', '') + if (!Number.isInteger(run) || run < 1) throw new Error(`not a workflow run number: ${run}`) + return `${core}-nightly.${day}.${run}` +} + +export function isNightly(version) { + return parseVersion(version).pre[0] === 'nightly' +} + +/** The nightly a file on the `updates` release is an update package of (full, file list, delta to it), or null. */ +function nightlyPackageVersion(name) { + const version = + parseDeltaName(name)?.to ?? + /^MossStudio-(.+)-(?:win32-x64|linux-x64|darwin-arm64|darwin-x64)-full\.zip$/.exec(name)?.[1] ?? + /^files-(.+)-(?:win32-x64|linux-x64|darwin-arm64|darwin-x64)\.json$/.exec(name)?.[1] + return version && isVersion(version) && isNightly(version) ? version : null +} + +/** + * The nightly update packages on the `updates` release to delete: those of every nightly but `keep` + * (the new one, and the one before it so a download already under way can finish). Feeds, their + * signatures and release packages are never among them. + */ +export function staleNightlyPackages(names, keep) { + const kept = new Set(keep.filter(Boolean)) + return names.filter((name) => { + const version = nightlyPackageVersion(name) + return version !== null && !kept.has(version) + }) +} + /** Parse a delta file name back into its parts (null when it is not one). */ export function parseDeltaName(file) { const match = /^MossStudio-(.+)-to-(.+)-(win32-x64|linux-x64|darwin-arm64|darwin-x64)\.delta\.zip$/.exec(file) diff --git a/tools/release/make-feed.mjs b/tools/release/make-feed.mjs index bafb47a4..d4dbdf28 100644 --- a/tools/release/make-feed.mjs +++ b/tools/release/make-feed.mjs @@ -4,6 +4,8 @@ // node tools/release/make-feed.mjs --version 1.2.3 [--artifacts dist/release/artifacts] // [--out ] [--current-stable update-stable.json] [--current-beta update-beta.json] // [--base-url http://127.0.0.1:8080/] [--channel stable ...] [--require-notes] +// node tools/release/make-feed.mjs --nightly --version 1.2.4-nightly.20261110.42 --notes-file notes.md +// [--current-nightly update-nightly.json] [--artifacts ...] [--out ...] // // Notes: release/notes/.md (optional front matter `minimumVersion: x.y.z`). // Announcements: release/announcements.json (optional array; expired `until` entries dropped). @@ -11,12 +13,14 @@ // whose current feed (--current-stable / --current-beta) is the same or newer is left alone. // --channel overrides that (tests). --base-url replaces the GitHub // release download URL (tests serve the artifacts locally). +// --nightly writes update-nightly.json only, pointing at the packages on the rolling `updates` +// release, and only when the published nightly feed (--current-nightly) holds an older version. import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DIST_DIR, main, REPO_ROOT } from './lib/cli.mjs' -import { assembleFeed, channelsFor, currentAnnouncements, describePlatforms, feedBytes, readNotes } from './lib/feed.mjs' -import { feedName, isVersion, RELEASE_REPO, releaseAssetUrl } from './lib/version.mjs' +import { assembleFeed, channelsFor, currentAnnouncements, describePlatforms, feedBytes, nightlySkipReason, readNotes } from './lib/feed.mjs' +import { feedName, isVersion, NIGHTLY_TAG, nightlyAssetUrl, RELEASE_REPO, releaseAssetUrl } from './lib/version.mjs' main(async () => { const { values } = parseArgs({ @@ -32,6 +36,9 @@ main(async () => { announcements: { type: 'string', default: join(REPO_ROOT, 'release', 'announcements.json') }, 'require-notes': { type: 'boolean', default: false }, 'published-at': { type: 'string' }, + nightly: { type: 'boolean', default: false }, + 'current-nightly': { type: 'string' }, + 'notes-file': { type: 'string' }, }, }) const version = values.version @@ -40,6 +47,11 @@ main(async () => { const out = resolve(values.out ?? artifacts) mkdirSync(out, { recursive: true }) + if (values.nightly) { + writeNightlyFeed(values, version, artifacts, out) + return + } + const baseUrl = values['base-url'] const urlFor = baseUrl ? (file) => new URL(encodeURIComponent(file), baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).href : (file) => releaseAssetUrl(version, file) const notes = readNotes(resolve(values['notes-dir']), version, { required: values['require-notes'] }) @@ -78,3 +90,24 @@ main(async () => { } if (channels.length === 0) console.log(`no feed written: ${version} is not newer than any channel`) }) + +function writeNightlyFeed(values, version, artifacts, out) { + const current = values['current-nightly'] && existsSync(values['current-nightly']) ? JSON.parse(readFileSync(values['current-nightly'], 'utf8')).version : undefined + const skip = nightlySkipReason(version, current) + if (skip) { + console.log(`no feed written: ${skip}`) + return + } + if (!values['notes-file']) throw new Error('--nightly needs --notes-file') + const feed = assembleFeed({ + channel: 'nightly', + version, + publishedAt: values['published-at'] ?? new Date().toISOString(), + notes: readFileSync(values['notes-file'], 'utf8').trim(), + notesUrl: `https://github.com/${RELEASE_REPO}/releases/tag/${NIGHTLY_TAG}`, + platforms: describePlatforms(artifacts, version, (file) => nightlyAssetUrl(file)), + }) + const path = join(out, feedName('nightly')) + writeFileSync(path, feedBytes(feed)) + console.log(`${path}: ${version} for ${Object.keys(feed.platforms).join(', ')}`) +} diff --git a/tools/release/verify-artifacts.mjs b/tools/release/verify-artifacts.mjs index 87e2de70..40b78a4b 100644 --- a/tools/release/verify-artifacts.mjs +++ b/tools/release/verify-artifacts.mjs @@ -2,7 +2,7 @@ // Re-verify built artifacts before they are uploaded. // // node tools/release/verify-artifacts.mjs --version 1.2.3 [--artifacts dist/release/artifacts] -// (--public-key | --keys | --production-keys) +// (--public-key | --keys | --production-keys | --nightly-keys) // [--previous ] [--url-prefix https://github.com/...] // [--platform linux-x64 ...] [--feeds ] [--no-feeds | --feeds-optional] // @@ -19,9 +19,9 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { DIST_DIR, main, run } from './lib/cli.mjs' -import { loadInstallContract, loadProductionKeys } from './lib/contract.mjs' +import { loadInstallContract, loadNightlyKeys, loadProductionKeys } from './lib/contract.mjs' import { keyIdFor } from './lib/sign.mjs' -import { artifactNames, isVersion, parseDeltaName, PLATFORMS, releaseAssetUrl } from './lib/version.mjs' +import { artifactNames, isVersion, nightlyAssetUrl, parseDeltaName, PLATFORMS, releaseAssetUrl } from './lib/version.mjs' import { checkDelta, checkFeed, checkFileList, checkFullZip, checkPortable, checkSetupFile } from './lib/verify.mjs' main(async () => { @@ -32,6 +32,8 @@ main(async () => { 'public-key': { type: 'string' }, keys: { type: 'string' }, 'production-keys': { type: 'boolean', default: false }, + // update-nightly.json only, signed by a NIGHTLY_UPDATE_KEYS key, packages on the `updates` release. + 'nightly-keys': { type: 'boolean', default: false }, previous: { type: 'string' }, 'url-prefix': { type: 'string' }, platform: { type: 'string', multiple: true }, @@ -89,18 +91,22 @@ main(async () => { if (values['no-feeds']) return let trusted - if (values['production-keys']) trusted = await loadProductionKeys() + if (values['nightly-keys']) trusted = await loadNightlyKeys() + else if (values['production-keys']) trusted = await loadProductionKeys() else if (values.keys) trusted = JSON.parse(values.keys) else if (values['public-key']) trusted = [{ id: keyIdFor(values['public-key']), publicKey: values['public-key'] }] else throw new Error('pass --public-key, --keys or --production-keys (or --no-feeds)') - if (trusted.length === 0) throw new Error('no trusted keys: PRODUCTION_UPDATE_KEYS in apps/desktop/src/shared/updateKeys.ts is empty (run pnpm release:keygen)') + const list = values['nightly-keys'] ? 'NIGHTLY_UPDATE_KEYS' : 'PRODUCTION_UPDATE_KEYS' + if (trusted.length === 0) throw new Error(`no trusted keys: ${list} in apps/desktop/src/shared/updateKeys.ts is empty (run pnpm release:keygen)`) const feedDir = resolve(values.feeds ?? dir) - const feeds = readdirSync(feedDir).filter((file) => /^update-(stable|beta)\.json$/.test(file)) + // The nightly key may sign the nightly feed only; the production keys never verify it here. + const feedPattern = values['nightly-keys'] ? /^update-nightly\.json$/ : /^update-(stable|beta)\.json$/ + const feeds = readdirSync(feedDir).filter((file) => feedPattern.test(file)) if (feeds.length === 0) { if (values['feeds-optional']) { console.log('no feeds to verify (no channel takes this version)'); return } throw new Error(`no update-*.json feeds to verify in ${feedDir}`) } - const urlPrefix = values['url-prefix'] ?? releaseAssetUrl(version, '') + const urlPrefix = values['url-prefix'] ?? (values['nightly-keys'] ? nightlyAssetUrl('') : releaseAssetUrl(version, '')) for (const file of feeds) { const feed = checkFeed(join(feedDir, file), { dir, trusted, updateFeedSchema: contract.updateFeedSchema, feedSignatureSchema: contract.feedSignatureSchema, expectVersion: version, urlPrefix }) console.log(`ok ${file} (${feed.version}, ${Object.keys(feed.platforms).join(', ')}, signature verified)`)