feat(release): publish desktop updates from a tag through one feed

Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.

Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
This commit is contained in:
Yun Chan 2026-09-16 23:23:00 +09:00
parent 65ecc7aabc
commit 7953706142
21 changed files with 1619 additions and 90 deletions

View file

@ -0,0 +1,163 @@
// tests/main/update-policy.test.ts
// 업데이트 정책 순수 로직 테스트: 버전 비교, 메이저/증분 판단, 강제 업데이트,
// 채널 게이팅, staged rollout.
import { describe, it, expect } from 'vitest'
import {
DEFAULT_UPDATE_POLICY,
compareVersions,
decideUpdate,
isChannelAcceptable,
isWithinRollout,
parseUpdatePolicy,
rolloutBucket,
type UpdatePolicy,
} from '../../src/main/update-policy'
import { channelMetadataName, isUpdateChannel } from '../../src/main/update-feed'
const policy: UpdatePolicy = {
...DEFAULT_UPDATE_POLICY,
minimumSupportedVersion: '1.0.0',
forceInstallBelow: '0.3.0',
fullInstallOnMajorChange: true,
fullInstallVersionGap: 3,
stagingPercentage: 100,
}
describe('compareVersions', () => {
it('주/부/패치를 순서대로 비교한다', () => {
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
expect(compareVersions('1.2.3', '1.2.4')).toBe(-1)
expect(compareVersions('1.3.0', '1.2.9')).toBe(1)
expect(compareVersions('2.0.0', '1.99.99')).toBe(1)
})
it('prerelease는 같은 코어의 stable보다 낮다', () => {
expect(compareVersions('1.0.0-alpha', '1.0.0')).toBe(-1)
expect(compareVersions('1.1.0-beta.2', '1.1.0-beta.10')).toBe(-1)
})
it('v 접두와 build metadata를 허용한다', () => {
expect(compareVersions('v1.1.0', '1.1.0+build.7')).toBe(0)
})
it('파싱 실패 시 null', () => {
expect(compareVersions('not-a-version', '1.0.0')).toBeNull()
})
})
describe('decideUpdate', () => {
it('최소 지원 버전 미만이면 강제 업데이트', () => {
const decision = decideUpdate(policy, '0.5.0', '1.1.0')
expect(decision.mandatory).toBe(true)
expect(decision.reason).toBe('below-minimum-supported')
})
it('forceInstallBelow 미만이면 강제 사유가 force-install (더 강한 조건이 우선)', () => {
const decision = decideUpdate(policy, '0.2.1-alpha', '1.1.0')
expect(decision.mandatory).toBe(true)
expect(decision.reason).toBe('below-force-install')
})
it('최소 지원 이상이면 일반 업데이트', () => {
const decision = decideUpdate(policy, '1.1.0', '1.2.0')
expect(decision.mandatory).toBe(false)
expect(decision.reason).toBe('standard')
expect(decision.forceFull).toBe(false)
})
it('major 승격이면 full 다운로드', () => {
const decision = decideUpdate(policy, '1.9.0', '2.0.0')
expect(decision.forceFull).toBe(true)
expect(decision.mandatory).toBe(false)
})
it('minor 갭이 임계 이상이면 full 다운로드', () => {
const decision = decideUpdate(policy, '1.0.0', '1.5.0')
expect(decision.forceFull).toBe(true)
})
it('major 정책이 꺼져 있으면 major 승격도 차분 가능', () => {
const relaxed = { ...policy, fullInstallOnMajorChange: false }
expect(decideUpdate(relaxed, '1.9.0', '2.0.0').forceFull).toBe(false)
})
it('현재 버전을 파싱할 수 없으면 전체 설치', () => {
expect(decideUpdate(policy, 'garbage', '1.2.0').forceFull).toBe(true)
})
})
describe('isChannelAcceptable', () => {
it('stable 채널은 prerelease를 받지 않는다', () => {
expect(isChannelAcceptable(false, '1.0.0', '1.1.0-beta')).toBe(false)
expect(isChannelAcceptable(false, '1.0.0', '1.1.0')).toBe(true)
})
it('beta 채널은 prerelease를 받는다', () => {
expect(isChannelAcceptable(true, '1.0.0', '1.1.0-beta')).toBe(true)
})
it('더 높은 버전이 아니면 거부한다', () => {
expect(isChannelAcceptable(false, '1.2.0', '1.1.0')).toBe(false)
})
})
describe('staged rollout', () => {
it('deviceId에 대해 결정적 버킷을 돌려준다', () => {
expect(rolloutBucket('device-a', '1.1.0')).toBe(rolloutBucket('device-a', '1.1.0'))
expect(rolloutBucket('device-a', '1.1.0')).toBeLessThan(100)
})
it('100%는 모두 포함, 0%는 모두 제외', () => {
const standard = { mandatory: false, forceFull: false, reason: 'standard' as const }
expect(isWithinRollout({ ...policy, stagingPercentage: 100 }, 'd', '1.1.0', standard)).toBe(true)
expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', standard)).toBe(false)
})
it('강제 업데이트는 rollout을 무시한다', () => {
const forced = { mandatory: true, forceFull: true, reason: 'below-force-install' as const }
expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', forced)).toBe(true)
})
})
describe('parseUpdatePolicy', () => {
it('유효한 필드를 반영하고 잘못된 필드는 기본값 유지', () => {
const parsed = parseUpdatePolicy({
schemaVersion: 1,
defaultChannel: 'beta',
minimumSupportedVersion: '1.2.0',
forceInstallBelow: null,
fullInstallOnMajorChange: false,
fullInstallVersionGap: 5,
stagingPercentage: 250,
killSwitch: true,
channels: { latest: { allowPrerelease: false }, rogue: {} },
})
expect(parsed.defaultChannel).toBe('beta')
expect(parsed.minimumSupportedVersion).toBe('1.2.0')
expect(parsed.forceInstallBelow).toBeNull()
expect(parsed.fullInstallOnMajorChange).toBe(false)
expect(parsed.fullInstallVersionGap).toBe(5)
expect(parsed.stagingPercentage).toBe(100)
expect(parsed.killSwitch).toBe(true)
expect(parsed.channels.beta.allowPrerelease).toBe(DEFAULT_UPDATE_POLICY.channels.beta.allowPrerelease)
})
it('잘못된 입력은 기본 정책', () => {
expect(parseUpdatePolicy(null)).toEqual(DEFAULT_UPDATE_POLICY)
expect(parseUpdatePolicy('nope')).toEqual(DEFAULT_UPDATE_POLICY)
})
})
describe('update-feed helpers', () => {
it('채널별 metadata 파일명', () => {
expect(channelMetadataName('latest')).toBe('latest.yml')
expect(channelMetadataName('beta')).toBe('beta.yml')
expect(channelMetadataName('alpha')).toBe('alpha.yml')
})
it('채널 식별', () => {
expect(isUpdateChannel('latest')).toBe(true)
expect(isUpdateChannel('canary')).toBe(false)
})
})