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:
parent
65ecc7aabc
commit
7953706142
21 changed files with 1619 additions and 90 deletions
|
|
@ -4,7 +4,11 @@ import { dirname, join } from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
// canonical updater feed: Forgejo Generic Package Registry
|
||||
const CANONICAL_UPDATE_FEED =
|
||||
'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest'
|
||||
// legacy mirror: GitLab Generic Registry (pre-Forgejo installs still poll this)
|
||||
const LEGACY_UPDATE_FEED =
|
||||
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
|
||||
|
||||
function read(path) {
|
||||
|
|
@ -14,6 +18,7 @@ function read(path) {
|
|||
function loadSurfaces(readSurface = read) {
|
||||
return {
|
||||
metadata: JSON.parse(readSurface('release/product-version.json')),
|
||||
updatePolicy: JSON.parse(readSurface('release/update-policy.json')),
|
||||
androidIdentity: JSON.parse(readSurface('release/android-release-identity.json')),
|
||||
releaseEvidencePublicKey: readSurface('release/mobile-release-evidence-public.pem'),
|
||||
desktopLicensePublicKey: readSurface('apps/desktop/resources/license/production-public.pem'),
|
||||
|
|
@ -23,13 +28,17 @@ function loadSurfaces(readSurface = read) {
|
|||
builder: readSurface('apps/desktop/electron-builder.yml'),
|
||||
electronVite: readSurface('apps/desktop/electron.vite.config.ts'),
|
||||
updateFeed: readSurface('apps/desktop/src/main/update-feed.ts'),
|
||||
updatePolicySource: readSurface('apps/desktop/src/main/update-policy.ts'),
|
||||
updateService: readSurface('apps/desktop/src/main/services/UpdateService.ts'),
|
||||
publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'),
|
||||
forgejoPublisher: readSurface('scripts/ci/publish-forgejo-release.mjs'),
|
||||
gitlab: readSurface('.gitlab-ci.yml'),
|
||||
github: readSurface('.github/workflows/release.yml'),
|
||||
githubMac: readSurface('.github/workflows/build-mac.yml'),
|
||||
githubSigning: readSurface('.github/workflows/release-signing-ca.yml'),
|
||||
forgejoLinux: readSurface('.forgejo/workflows/deploy-site.yml'),
|
||||
forgejoWindows: readSurface('.forgejo/workflows/deploy-site-windows.yml'),
|
||||
forgejoRelease: readSurface('.forgejo/workflows/release.yml'),
|
||||
changelog: readSurface('CHANGELOG.md'),
|
||||
}
|
||||
}
|
||||
|
|
@ -121,17 +130,78 @@ function validate(surfaces) {
|
|||
fail(electronVersion === lockedElectron, 'electron_package_lock_drift')
|
||||
fail(builderElectron === lockedElectron, 'electron_builder_lock_drift')
|
||||
|
||||
// ── canonical feed contract: runtime == builder == Forgejo canonical ──
|
||||
const sourceFeed = surfaces.updateFeed.match(/UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1]
|
||||
const legacyFeed = surfaces.updateFeed.match(/LEGACY_UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1]
|
||||
const builderFeed = surfaces.builder.match(/publish:\s*[\s\S]*?\n\s+url:\s*["']([^"']+)["']/)?.[1]
|
||||
fail(sourceFeed === CANONICAL_UPDATE_FEED, 'desktop_runtime_update_feed_drift')
|
||||
fail(builderFeed === CANONICAL_UPDATE_FEED, 'desktop_builder_update_feed_drift')
|
||||
fail(!/\/releases\/\d+\.\d+\.\d+/.test(sourceFeed ?? ''), 'desktop_update_feed_version_pinned')
|
||||
fail(legacyFeed === LEGACY_UPDATE_FEED, 'desktop_legacy_mirror_feed_missing')
|
||||
|
||||
// ── update policy SSOT ──
|
||||
const policy = surfaces.updatePolicy
|
||||
fail(policy?.schemaVersion === 1, 'update_policy_schema_invalid')
|
||||
fail(
|
||||
['latest', 'beta', 'alpha'].every((channel) => typeof policy?.channels?.[channel]?.allowPrerelease === 'boolean'),
|
||||
'update_policy_channels_missing',
|
||||
)
|
||||
fail(
|
||||
['latest', 'beta', 'alpha'].includes(policy?.defaultChannel),
|
||||
'update_policy_default_channel_invalid',
|
||||
)
|
||||
fail(/^\d+\.\d+\.\d+$/.test(policy?.minimumSupportedVersion ?? ''), 'update_policy_minimum_invalid')
|
||||
fail(
|
||||
policy?.forceInstallBelow === null || /^\d+\.\d+\.\d+$/.test(policy?.forceInstallBelow ?? ''),
|
||||
'update_policy_force_install_invalid',
|
||||
)
|
||||
fail(typeof policy?.fullInstallOnMajorChange === 'boolean', 'update_policy_major_policy_missing')
|
||||
fail(
|
||||
Number.isSafeInteger(policy?.fullInstallVersionGap) && policy.fullInstallVersionGap >= 0,
|
||||
'update_policy_version_gap_invalid',
|
||||
)
|
||||
fail(
|
||||
Number.isSafeInteger(policy?.stagingPercentage) && policy.stagingPercentage >= 0 && policy.stagingPercentage <= 100,
|
||||
'update_policy_staging_invalid',
|
||||
)
|
||||
fail(typeof policy?.killSwitch === 'boolean', 'update_policy_kill_switch_missing')
|
||||
fail(
|
||||
surfaces.updatePolicySource.includes('decideUpdate') &&
|
||||
surfaces.updatePolicySource.includes('fullInstallVersionGap') &&
|
||||
surfaces.updatePolicySource.includes('isWithinRollout'),
|
||||
'update_policy_runtime_logic_missing',
|
||||
)
|
||||
fail(
|
||||
surfaces.updateService.includes('decideUpdate') &&
|
||||
surfaces.updateService.includes('isWithinRollout') &&
|
||||
/\bkillSwitch\b/.test(surfaces.updateService),
|
||||
'update_service_policy_enforcement_missing',
|
||||
)
|
||||
fail(
|
||||
surfaces.updateService.includes('disableDifferentialDownload'),
|
||||
'update_service_differential_control_missing',
|
||||
)
|
||||
|
||||
// ── legacy GitLab publisher (mirror) ──
|
||||
fail(surfaces.publisher.includes('const latestFiles = [...sortedFiles].sort'), 'publisher_asset_first_order_missing')
|
||||
fail(!surfaces.publisher.includes('deletePackagesForVersion("latest")'), 'publisher_deletes_live_feed_first')
|
||||
fail(surfaces.publisher.includes('verifyPublicLatestFile'), 'publisher_public_metadata_verification_missing')
|
||||
fail(surfaces.publisher.includes('release/product-version.json'), 'publisher_product_version_gate_missing')
|
||||
|
||||
// ── canonical Forgejo publisher contract ──
|
||||
fail(!/1\.0\.0/.test(surfaces.forgejoPublisher), 'forgejo_publisher_hardcoded_version')
|
||||
fail(surfaces.forgejoPublisher.includes('release/product-version.json'), 'forgejo_publisher_version_gate_missing')
|
||||
fail(surfaces.forgejoPublisher.includes('const latestOrder'), 'forgejo_publisher_asset_first_order_missing')
|
||||
fail(surfaces.forgejoPublisher.includes('validateUpdateMetadataReferences'), 'forgejo_publisher_metadata_reference_check_missing')
|
||||
fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing')
|
||||
fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing')
|
||||
fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing')
|
||||
fail(
|
||||
surfaces.forgejoPublisher.includes('/api/packages/') &&
|
||||
surfaces.forgejoPublisher.includes('generic'),
|
||||
'forgejo_publisher_registry_path_missing',
|
||||
)
|
||||
|
||||
for (const [name, workflow] of [
|
||||
['gitlab', surfaces.gitlab],
|
||||
['github', surfaces.github],
|
||||
|
|
@ -140,8 +210,13 @@ function validate(surfaces) {
|
|||
fail(workflow.includes('release/product-version.json'), `${name}_product_metadata_missing`)
|
||||
fail(!workflow.includes('1000000 + CI_PIPELINE_IID'), `${name}_pipeline_counter_version_code`)
|
||||
fail(!workflow.includes('1000000 + GITHUB_RUN_NUMBER'), `${name}_run_counter_version_code`)
|
||||
fail(workflow.includes('publish-forgejo-release.mjs'), `${name}_forgejo_publish_missing`)
|
||||
}
|
||||
|
||||
fail(surfaces.forgejoRelease.includes('publish-forgejo-release.mjs'), 'forgejo_release_workflow_publish_missing')
|
||||
fail(/tags:/.test(surfaces.forgejoRelease), 'forgejo_release_workflow_tag_trigger_missing')
|
||||
fail(surfaces.forgejoRelease.includes('sync-version.mjs'), 'forgejo_release_workflow_version_gate_missing')
|
||||
|
||||
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubMac), 'legacy_mac_tag_trigger_enabled')
|
||||
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubSigning), 'legacy_signing_tag_trigger_enabled')
|
||||
fail(!surfaces.forgejoLinux.includes('sync-and-publish-forgejo-release'), 'forgejo_linux_legacy_release_sync')
|
||||
|
|
@ -163,6 +238,9 @@ function validate(surfaces) {
|
|||
androidVersionCode: metadata.androidVersionCode,
|
||||
iosBuildNumber: metadata.iosBuildNumber,
|
||||
updateFeed: sourceFeed,
|
||||
legacyUpdateFeed: legacyFeed,
|
||||
updateChannel: policy.defaultChannel,
|
||||
minimumSupportedVersion: policy.minimumSupportedVersion,
|
||||
electronVersion: lockedElectron,
|
||||
releaseEvidenceKeyId: evidenceKeyId,
|
||||
desktopLicensePublicKeyId: desktopLicenseKeyId,
|
||||
|
|
@ -196,6 +274,55 @@ if (process.argv.includes('--self-test')) {
|
|||
},
|
||||
'desktop_runtime_update_feed_drift',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.updateFeed = candidate.updateFeed.replace(LEGACY_UPDATE_FEED, 'https://example.invalid/legacy')
|
||||
},
|
||||
'desktop_legacy_mirror_feed_missing',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.updatePolicy.minimumSupportedVersion = 'not-semver'
|
||||
},
|
||||
'update_policy_minimum_invalid',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.updatePolicy.stagingPercentage = 140
|
||||
},
|
||||
'update_policy_staging_invalid',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.forgejoPublisher = 'console.log("1.0.0 is hardcoded")'
|
||||
},
|
||||
'forgejo_publisher_hardcoded_version',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.forgejoPublisher = candidate.forgejoPublisher.replace('const latestOrder', 'const uploadOrder')
|
||||
},
|
||||
'forgejo_publisher_asset_first_order_missing',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.gitlab = candidate.gitlab.replace('publish-forgejo-release.mjs', 'publish-gitlab-release.mjs')
|
||||
},
|
||||
'gitlab_forgejo_publish_missing',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.forgejoRelease = candidate.forgejoRelease.replace('tags:', 'branches:')
|
||||
},
|
||||
'forgejo_release_workflow_tag_trigger_missing',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
|
|
@ -224,6 +351,13 @@ if (process.argv.includes('--self-test')) {
|
|||
},
|
||||
'desktop_license_public_key_id_drift',
|
||||
)
|
||||
expectRejected(
|
||||
surfaces,
|
||||
(candidate) => {
|
||||
candidate.updateService = candidate.updateService.replaceAll('killSwitch', 'killSwitchDisabled')
|
||||
},
|
||||
'update_service_policy_enforcement_missing',
|
||||
)
|
||||
let missingDesktopKeyRejected = false
|
||||
try {
|
||||
loadSurfaces((path) => {
|
||||
|
|
@ -239,7 +373,7 @@ if (process.argv.includes('--self-test')) {
|
|||
if (!missingDesktopKeyRejected) {
|
||||
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing')
|
||||
}
|
||||
result.negativeCases = 6
|
||||
result.negativeCases = 13
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue