mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
fix(stage-tamagotchi): keep four update channels and report health after the stage loads (#3165)
This commit is contained in:
@@ -22,16 +22,17 @@ describe('planUpdates', () => {
|
||||
{ tag: 'v0.13.0', channel: 'stable' },
|
||||
{ tag: '0.13.0', channel: 'stable' },
|
||||
{ tag: 'v0.13.0+build.7', channel: 'stable' },
|
||||
{ tag: 'v0.13.0-beta.1', channel: 'beta' },
|
||||
{ tag: 'v0.13.0-alpha.36', channel: 'alpha' },
|
||||
{ tag: 'v0.13.0-Nightly.20261026', channel: 'nightly' },
|
||||
{ tag: 'v0.13.0-beta.1', channel: 'canary' },
|
||||
{ tag: 'v0.13.0-alpha.36', channel: 'canary' },
|
||||
{ tag: 'v0.13.0-rc.1', channel: 'canary' },
|
||||
{ tag: 'v0.13.0-1', channel: 'canary' },
|
||||
{ tag: 'v0.13.0-canary1', channel: 'canary' },
|
||||
])('publishes the release $tag to $channel with the version of package.json', ({ tag, channel }) => {
|
||||
expect(planUpdates({ ...base, event: 'release', tag })).toEqual({ ...planUpdates({ ...base, event: 'push' }), channel, version: '0.13.0' })
|
||||
})
|
||||
|
||||
// A prerelease that names no channel must never fall back to stable, or stable installs would take it.
|
||||
it.each(['v0.13.0-rc.1', 'v0.13.0-1', 'v0.13.0.1', 'release-2026', 'v0.13'])('publishes nothing for the tag %s', (tag) => {
|
||||
// A tag that is not a version must never fall back to stable, or stable installs would take it.
|
||||
it.each(['v0.13.0.1', 'release-2026', 'v0.13'])('publishes nothing for the tag %s', (tag) => {
|
||||
const plan = planUpdates({ ...base, event: 'release', tag })
|
||||
|
||||
expect(plan.channel).toBeUndefined()
|
||||
@@ -39,7 +40,7 @@ describe('planUpdates', () => {
|
||||
})
|
||||
|
||||
it('publishes a manual release run with a tag, and nothing for other manual runs', () => {
|
||||
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0-beta.2' }).channel).toBe('beta')
|
||||
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0-beta.2' }).channel).toBe('canary')
|
||||
expect(planUpdates({ ...base, event: 'workflow_dispatch' })).toMatchObject({ skipReason: 'This run is not a release.' })
|
||||
expect(planUpdates({ ...base, event: 'workflow_dispatch' }).channel).toBeUndefined()
|
||||
expect(planUpdates({ ...base, event: 'workflow_dispatch', tag: 'v0.13.0', buildOnly: true }).channel).toBeUndefined()
|
||||
|
||||
@@ -3,9 +3,6 @@ import process from 'node:process'
|
||||
import { appendFile, readFile } from 'node:fs/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
/** The update channels that a release tag can name with its prerelease, as `ergo.config.ts` derives them. */
|
||||
const PRERELEASE_CHANNELS = ['alpha', 'beta', 'nightly', 'canary'] as const
|
||||
|
||||
export interface PlanInput {
|
||||
/** `github.event_name`. */
|
||||
event: string
|
||||
@@ -40,9 +37,9 @@ export interface UpdatePlan {
|
||||
*
|
||||
* - A push to main publishes to `dev`, and a scheduled run to `nightly`. Their versions name the build, for example
|
||||
* `0.12.0+dev.45.1bf946f`. Semver ignores the part after `+`, so the about page does not take them for a downgrade.
|
||||
* - A release, or a manual release run with a tag, publishes to the lane of the tag: `v0.13.0-beta.1` to `beta`, and
|
||||
* `v0.13.0` to `stable`. A prerelease of another name, for example `v0.13.0-rc.1`, publishes nothing, so it never
|
||||
* reaches stable.
|
||||
* - A release, or a manual release run with a tag, publishes a prerelease such as `v0.13.0-rc.1` or `v0.13.0-beta.1`
|
||||
* to `canary`, and `v0.13.0` to `stable`. `channelOfVersion` of `apps/stage-tamagotchi/src/shared/update-channels.ts`
|
||||
* gives the same channel for the version, so an install follows the channel that gets its next release.
|
||||
* - Other manual runs publish nothing.
|
||||
*/
|
||||
export function planUpdates(input: PlanInput): UpdatePlan {
|
||||
@@ -66,15 +63,7 @@ export function planUpdates(input: PlanInput): UpdatePlan {
|
||||
const match = /^v?\d+\.\d+\.\d+(?:-([0-9a-z.-]+))?(?:\+[0-9a-z.-]+)?$/i.exec(input.tag)
|
||||
if (!match)
|
||||
return { ...ids, skipReason: `The tag ${input.tag} is not a version.`, version }
|
||||
if (match[1] === undefined)
|
||||
return { ...ids, channel: 'stable', version }
|
||||
|
||||
// The name of the prerelease: `beta` of `beta.1`, and of `beta1`.
|
||||
const prerelease = /^[a-z]+/i.exec(match[1])?.[0].toLowerCase()
|
||||
if (prerelease !== undefined && (PRERELEASE_CHANNELS as readonly string[]).includes(prerelease))
|
||||
return { ...ids, channel: prerelease, version }
|
||||
|
||||
return { ...ids, skipReason: `The tag ${input.tag} names no update channel.`, version }
|
||||
return { ...ids, channel: match[1] === undefined ? 'stable' : 'canary', version }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -3,19 +3,17 @@ import { env } from 'node:process'
|
||||
|
||||
import { defineConfig } from '@ergosphere-it/electron/config'
|
||||
|
||||
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
// Node loads this file with type stripping, which resolves only full file names.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import { channelOfVersion } from './src/shared/update-channels.ts'
|
||||
|
||||
/**
|
||||
* The channel of a build: the prerelease name of the version, for example `beta` for `0.9.0-beta.4`, or `stable`.
|
||||
* The app can switch to another lane at run time, see `src/main/services/electron/auto-updater.ts`.
|
||||
*/
|
||||
function laneOf(version: string) {
|
||||
const prerelease = /^\d+\.\d+\.\d+-([a-z]+)/i.exec(version)?.[1]?.toLowerCase()
|
||||
return prerelease && ['alpha', 'beta', 'nightly', 'canary'].includes(prerelease) ? prerelease : 'stable'
|
||||
}
|
||||
// CI names each build in `ERGOSPHERE_IT_VERSION`, for example `0.13.0+nightly.12.13eef81`. The build uses the same name.
|
||||
const version = env.ERGOSPHERE_IT_VERSION
|
||||
|| (JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
|
||||
export default defineConfig({
|
||||
channel: env.AIRI_UPDATE_CHANNEL || laneOf(version),
|
||||
// The app can switch to another channel at run time, see `src/main/services/electron/auto-updater.ts`.
|
||||
channel: env.AIRI_UPDATE_CHANNEL || channelOfVersion(version),
|
||||
// Steam updates the Steam build. Flatpak and Nix are known only at runtime, see src/launcher/index.ts.
|
||||
disabled: env.VITE_DISTRIBUTION === 'steam',
|
||||
// A test build can point at another feed and key, for example a local S3.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { parse } from 'valibot'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { globalAppConfigSchema } from './global'
|
||||
|
||||
describe('globalAppConfigSchema', () => {
|
||||
it.each([
|
||||
{ stored: 'stable', expected: 'stable' },
|
||||
{ stored: 'dev', expected: 'dev' },
|
||||
{ stored: 'latest', expected: undefined },
|
||||
{ stored: 'alpha', expected: 'canary' },
|
||||
{ stored: 'beta', expected: 'canary' },
|
||||
])('reads the stored update channel $stored as $expected', ({ stored, expected }) => {
|
||||
expect(parse(globalAppConfigSchema, { updateChannel: stored }).updateChannel).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { array, boolean, object, optional, picklist, string } from 'valibot'
|
||||
import type { UpdateChannel } from '../../shared/update-channels'
|
||||
|
||||
import { array, boolean, object, optional, picklist, pipe, string, transform } from 'valibot'
|
||||
|
||||
import { createConfig } from '../libs/electron/persistence'
|
||||
|
||||
@@ -7,11 +9,30 @@ const shortcutAcceleratorSchema = object({
|
||||
key: string(),
|
||||
})
|
||||
|
||||
// NOTICE:
|
||||
// Releases up to v0.13.0 let the user choose `latest`, `alpha`, and `beta`, which the store never had.
|
||||
// `latest` becomes auto. `alpha` and `beta` become `canary`, which gets every prerelease.
|
||||
// Remove the old names after the next stable release.
|
||||
const storedUpdateChannels = {
|
||||
stable: 'stable',
|
||||
canary: 'canary',
|
||||
nightly: 'nightly',
|
||||
dev: 'dev',
|
||||
latest: undefined,
|
||||
alpha: 'canary',
|
||||
beta: 'canary',
|
||||
} as const satisfies Record<UpdateChannel | 'latest' | 'alpha' | 'beta', UpdateChannel | undefined>
|
||||
|
||||
const updateChannelSchema = pipe(
|
||||
picklist(['stable', 'canary', 'nightly', 'dev', 'latest', 'alpha', 'beta']),
|
||||
transform(channel => storedUpdateChannels[channel]),
|
||||
)
|
||||
|
||||
export const globalAppConfigSchema = object({
|
||||
hideAppIcon: optional(boolean()),
|
||||
language: optional(string()),
|
||||
spotlightShortcutAccelerator: optional(shortcutAcceleratorSchema),
|
||||
updateChannel: optional(picklist(['latest', 'stable', 'alpha', 'beta', 'nightly', 'canary', 'dev'])),
|
||||
updateChannel: optional(updateChannelSchema),
|
||||
})
|
||||
|
||||
export function createGlobalAppConfig() {
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('setupAutoUpdater', () => {
|
||||
|
||||
it.each([
|
||||
{ name: 'the lane that the user chose', stored: 'nightly', env: undefined, expected: 'nightly' },
|
||||
{ name: 'AIRI_UPDATE_CHANNEL', stored: 'nightly', env: 'alpha', expected: 'alpha' },
|
||||
{ name: 'AIRI_UPDATE_CHANNEL', stored: 'nightly', env: 'dev', expected: 'dev' },
|
||||
] as const)('follows $name', async ({ stored, env, expected }) => {
|
||||
if (env)
|
||||
vi.stubEnv('AIRI_UPDATE_CHANNEL', env)
|
||||
@@ -159,6 +159,20 @@ describe('setupAutoUpdater', () => {
|
||||
expect(fake.updater.channel).toBe('ota-test')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ saved: 'latest', version: '0.13.0+dev.821.13eef81', expected: 'dev' },
|
||||
{ saved: 'beta', version: '0.14.0-rc.1', expected: 'canary' },
|
||||
])('leaves the removed channel $saved that the launcher saved, for the channel of $version', async ({ saved, version, expected }) => {
|
||||
bundleState.version = version
|
||||
const fake = createFakeUpdater()
|
||||
fake.updater.channel = saved
|
||||
const { setupAutoUpdater } = await import('./auto-updater')
|
||||
|
||||
setupAutoUpdater({ updater: fake.updater })
|
||||
|
||||
expect(fake.updater.channel).toBe(expected)
|
||||
})
|
||||
|
||||
it('switches the channel, saves the choice, and checks again when the user picks a lane', async () => {
|
||||
const fake = createFakeUpdater()
|
||||
const setStoredUpdateLane = vi.fn()
|
||||
@@ -173,14 +187,21 @@ describe('setupAutoUpdater', () => {
|
||||
expect(service.getPreferredUpdateLane()).toBe('nightly')
|
||||
})
|
||||
|
||||
it('goes back to the lane of the build when the user picks auto', async () => {
|
||||
it.each([
|
||||
{ version: '0.9.0-beta.4', expected: 'canary' },
|
||||
{ version: '0.13.0+nightly.12.13eef81', expected: 'nightly' },
|
||||
{ version: '0.13.0+dev.821.13eef81', expected: 'dev' },
|
||||
{ version: '0.13.0', expected: 'stable' },
|
||||
])('goes back to the channel of $version when the user picks auto', async ({ version, expected }) => {
|
||||
bundleState.version = version
|
||||
const fake = createFakeUpdater()
|
||||
fake.updater.channel = 'canary'
|
||||
const { setupAutoUpdater } = await import('./auto-updater')
|
||||
const service = setupAutoUpdater({ updater: fake.updater, getStoredUpdateLane: () => 'nightly' })
|
||||
const service = setupAutoUpdater({ updater: fake.updater, getStoredUpdateLane: () => 'dev' })
|
||||
|
||||
await service.setPreferredUpdateLane(undefined)
|
||||
|
||||
expect(fake.updater.channel).toBe('beta')
|
||||
expect(fake.updater.channel).toBe(expected)
|
||||
})
|
||||
|
||||
it('applies a ready update once while two clicks arrive at once', async () => {
|
||||
|
||||
@@ -10,8 +10,6 @@ import process from 'node:process'
|
||||
import { appendFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, join, normalize } from 'node:path'
|
||||
|
||||
import semver from 'semver'
|
||||
|
||||
import { activeBundle, updater as ergosphereUpdater } from '@ergosphere-it/electron/main'
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
electronGetUpdaterPreferences,
|
||||
electronSetUpdaterPreferences,
|
||||
} from '../../../shared/eventa'
|
||||
import { channelOfVersion, updateChannels } from '../../../shared/update-channels'
|
||||
|
||||
const UPDATE_CHANNEL_ENV_KEY = 'AIRI_UPDATE_CHANNEL'
|
||||
|
||||
@@ -48,35 +47,15 @@ async function logToFile(level: 'INFO' | 'WARN' | 'ERROR', message: string) {
|
||||
export type UpdateLane = ElectronUpdaterChannel
|
||||
|
||||
function normalizeLane(value: string | undefined): UpdateLane | undefined {
|
||||
if (!value)
|
||||
return undefined
|
||||
|
||||
switch (value.toLowerCase()) {
|
||||
case 'stable':
|
||||
case 'latest':
|
||||
case 'alpha':
|
||||
case 'beta':
|
||||
case 'nightly':
|
||||
case 'canary':
|
||||
case 'dev':
|
||||
return value.toLowerCase() as UpdateLane
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function laneFromVersion(version: string): UpdateLane {
|
||||
const prerelease = semver.prerelease(version)?.[0]?.toString().toLowerCase()
|
||||
return normalizeLane(prerelease) ?? 'stable'
|
||||
const lane = value?.toLowerCase()
|
||||
return updateChannels.find(channel => channel === lane)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane that the user or the environment chose: `AIRI_UPDATE_CHANNEL`, then the channel that the user chose.
|
||||
* Without one, the app follows the channel of its build, which `ergo.config.ts` derives from the version, so a beta
|
||||
* build follows `beta`.
|
||||
* Without one, the app follows the channel of its version, see `channelOfVersion`, so a nightly build follows `nightly`.
|
||||
*
|
||||
* Each lane is a channel of the ergosphere-it release store with the same name. `latest` is the channel that every
|
||||
* release is promoted to. `dev` gets the bundle of each push to main, from `.github/workflows/release-tamagotchi.yml`.
|
||||
* Each lane is a channel of the ergosphere-it release store with the same name.
|
||||
*/
|
||||
export function getChosenUpdateLane(storedLane: UpdateLane | undefined): UpdateLane | undefined {
|
||||
return normalizeLane(process.env[UPDATE_CHANNEL_ENV_KEY]?.trim()) ?? storedLane
|
||||
@@ -304,9 +283,16 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
log.withError(error).error(reason)
|
||||
}
|
||||
|
||||
const chosenLane = getChosenUpdateLane(storedPreferredLane)
|
||||
if (updater.status.status !== 'disabled' && chosenLane)
|
||||
follow(chosenLane)
|
||||
if (updater.status.status !== 'disabled') {
|
||||
// NOTICE:
|
||||
// Up to v0.13.0, the launcher saved `latest`, `alpha`, or `beta`, which the store never had. Each check got a 404.
|
||||
// `src/main/configs/global.ts` maps the choice of the user. This maps the channel that the launcher saved.
|
||||
// Remove it after the next stable release, with the mapping in `global.ts`.
|
||||
const savedRemovedChannel = ['latest', 'alpha', 'beta'].includes(updater.channel)
|
||||
const lane = getChosenUpdateLane(storedPreferredLane) ?? (savedRemovedChannel ? channelOfVersion(activeBundle.version) : undefined)
|
||||
if (lane)
|
||||
follow(lane)
|
||||
}
|
||||
|
||||
updater.onEvent((event: LauncherEvent) => {
|
||||
// A download reports progress many times a second. Its start and its end are statuses of their own.
|
||||
@@ -334,8 +320,8 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
let applying: Promise<void> | undefined
|
||||
|
||||
// The launcher keeps a new bundle only when it reports healthy, and rolls it back otherwise. The bundle works when
|
||||
// main started every module and the main window mounted its page. A page-only update reloads the windows in place,
|
||||
// so its page reports again after the reload.
|
||||
// main started every module and the main window finished loading its stage. After a page-only update, the reloaded
|
||||
// page reports again.
|
||||
let mainReady = false
|
||||
let pageMounted = false
|
||||
function reportHealthWhenReady() {
|
||||
@@ -384,8 +370,7 @@ export function setupAutoUpdater(options: AutoUpdaterOptions = {}): AutoUpdater
|
||||
|
||||
storedPreferredLane = lane
|
||||
options.setStoredUpdateLane?.(lane)
|
||||
// Auto goes back to the lane of the running version, as `ergo.config.ts` derives the channel of a build.
|
||||
const next = getChosenUpdateLane(lane) ?? laneFromVersion(activeBundle.version)
|
||||
const next = getChosenUpdateLane(lane) ?? channelOfVersion(activeBundle.version)
|
||||
if (updater.status.status !== 'disabled' && follow(next))
|
||||
await updater.checkForUpdates().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { electronAppGetVersion, electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
|
||||
import { updateChannels } from '../../shared/update-channels'
|
||||
|
||||
const buildInfo = useBuildInfo()
|
||||
// The main process owns the app version. Packaging can change it after the renderer build, so `buildInfo.version`
|
||||
@@ -64,12 +65,10 @@ const updaterErrorMessage = computed(() => {
|
||||
const showChangelog = ref(false)
|
||||
const showBugReportDialog = ref(false)
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const updateChannelOptions = ['auto', 'latest', 'stable', 'alpha', 'beta', 'nightly', 'canary', 'dev'] as const
|
||||
const updateChannelOptions = ['auto', ...updateChannels] as const
|
||||
type UpdateChannelOption = typeof updateChannelOptions[number]
|
||||
const updateChannelSelectOptions = computed(() => updateChannelOptions.map(channel => ({
|
||||
label: channel === 'latest'
|
||||
? 'Latest (any release)'
|
||||
: t(`tamagotchi.stage.about.update.channels.${channel}`),
|
||||
label: t(`tamagotchi.stage.about.update.channels.${channel}`),
|
||||
value: channel,
|
||||
})))
|
||||
const selectedUpdateChannel = ref<UpdateChannelOption>('auto')
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useFeatureFlagsStore } from '@proj-airi/stage-ui/stores/feature-flags'
|
||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useVoiceStore } from '@proj-airi/stage-ui/stores/voice'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { refDebounced, until } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, onMounted, onScopeDispose, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -199,9 +199,10 @@ useElectronEventaInvoke(electronAppIsWayland)()
|
||||
.then(value => isWayland.value = value)
|
||||
.catch(error => console.warn('[Main Page] Failed to detect Wayland; the controls Island stays shown:', errorMessageFrom(error)))
|
||||
|
||||
// The update launcher keeps a new bundle only when it reports healthy. The main window mounting this page is part of it.
|
||||
// The update launcher keeps a new bundle only when it reports healthy, and rolls it back otherwise. This page reports
|
||||
// when the stage stops loading, with the model or with the error surface. A page stuck on loading never reports.
|
||||
const reportPageMounted = useElectronEventaInvoke(electronMainPageMounted)
|
||||
onMounted(() => void reportPageMounted())
|
||||
until(isLoading).toBe(false).then(() => reportPageMounted())
|
||||
|
||||
const cursorAwayFromWindow = computed(() => !isWayland.value && isOutsideWindow.value && !isAroundWindowBorder.value)
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ import type {
|
||||
import type { ChatHistoryReplyPayload } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import type { Rectangle } from 'electron'
|
||||
|
||||
import type { UpdateChannel } from '../update-channels'
|
||||
|
||||
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
|
||||
@@ -332,7 +334,7 @@ export const electronGetServerChannelConfig = defineInvokeEventa<ElectronServerC
|
||||
export const electronApplyServerChannelConfig = defineInvokeEventa<ElectronServerChannelConfig, Partial<ElectronServerChannelConfig>>('eventa:invoke:electron:server-channel:apply-config')
|
||||
export const electronGetServerChannelQrPayload = defineInvokeEventa<ServerChannelQrPayload>('eventa:invoke:electron:server-channel:get-qr-payload')
|
||||
|
||||
export type ElectronUpdaterChannel = 'latest' | 'stable' | 'alpha' | 'beta' | 'nightly' | 'canary' | 'dev'
|
||||
export type ElectronUpdaterChannel = UpdateChannel
|
||||
|
||||
export interface ElectronUpdaterPreferences {
|
||||
channel?: ElectronUpdaterChannel
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { channelOfVersion } from './update-channels'
|
||||
|
||||
describe('channelOfVersion', () => {
|
||||
it.each([
|
||||
{ version: '0.13.0+dev.821.13eef81', expected: 'dev' },
|
||||
{ version: '0.13.0+nightly.12.13eef81', expected: 'nightly' },
|
||||
{ version: '0.14.0-rc.1+dev.3.13eef81', expected: 'dev' },
|
||||
{ version: '0.14.0-rc.1', expected: 'canary' },
|
||||
{ version: '0.14.0-beta.2', expected: 'canary' },
|
||||
{ version: 'v0.14.0-alpha.1', expected: 'canary' },
|
||||
{ version: '0.13.0', expected: 'stable' },
|
||||
{ version: '0.13.0+build.7', expected: 'stable' },
|
||||
])('maps $version to $expected', ({ version, expected }) => {
|
||||
expect(channelOfVersion(version)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The update channels of the ergosphere-it release store, as `.github/scripts/plan-tamagotchi-updates.ts` publishes
|
||||
* them.
|
||||
*
|
||||
* - `stable`: releases.
|
||||
* - `canary`: prereleases, before they become releases.
|
||||
* - `nightly`: the build of each day.
|
||||
* - `dev`: the build of each push to main.
|
||||
*/
|
||||
export const updateChannels = ['stable', 'canary', 'nightly', 'dev'] as const
|
||||
|
||||
export type UpdateChannel = typeof updateChannels[number]
|
||||
|
||||
/**
|
||||
* Returns the channel that a version comes from. With no channel that the user chose, the app follows this channel, and
|
||||
* `ergo.config.ts` builds it in.
|
||||
*
|
||||
* Build metadata names a build of main, and a prerelease names a release candidate.
|
||||
*
|
||||
* @example
|
||||
* channelOfVersion('0.13.0+dev.821.13eef81') // 'dev'
|
||||
* channelOfVersion('0.13.0+nightly.12.13eef81') // 'nightly'
|
||||
* channelOfVersion('0.14.0-rc.1') // 'canary'
|
||||
* channelOfVersion('0.13.0') // 'stable'
|
||||
*/
|
||||
export function channelOfVersion(version: string): UpdateChannel {
|
||||
const match = /^v?\d+\.\d+\.\d+(?:-([0-9a-z.-]+))?(?:\+([0-9a-z.-]+))?$/i.exec(version)
|
||||
const build = match?.[2]?.split('.')[0]?.toLowerCase()
|
||||
if (build === 'dev' || build === 'nightly')
|
||||
return build
|
||||
if (match?.[1] !== undefined)
|
||||
return 'canary'
|
||||
|
||||
return 'stable'
|
||||
}
|
||||
@@ -127,9 +127,9 @@ about:
|
||||
stable: Stable
|
||||
alpha: Alpha
|
||||
beta: Beta
|
||||
nightly: Nightly
|
||||
canary: Canary
|
||||
dev: Dev (every change on main)
|
||||
nightly: Nightly
|
||||
dev: Dev
|
||||
status:
|
||||
downloading: Downloading update...
|
||||
error-prefix: Error
|
||||
|
||||
@@ -120,8 +120,9 @@ about:
|
||||
stable: 稳定版
|
||||
alpha: Alpha 测试版
|
||||
beta: Beta 测试版
|
||||
nightly: 每夜构建
|
||||
canary: 尝鲜版
|
||||
nightly: 每日更新版
|
||||
dev: 开发版
|
||||
status:
|
||||
downloading: 正在下载更新......
|
||||
error-prefix: 错误
|
||||
|
||||
Reference in New Issue
Block a user