* ♻️ refactor: implement config-based update system with version compatibility control Replace GitHub API-based update discovery with JSON config file system. Support version gating (users below v1.7 must upgrade to v1.7.0 before v2.0). Auto-select GitHub/GitCode config source based on IP location. Simplify fallback logic. Changes: - Add update-config.json with version compatibility rules - Implement _fetchUpdateConfig() and _findCompatibleChannel() - Remove legacy _getReleaseVersionFromGithub() and GitHub API dependency - Refactor _setFeedUrl() with simplified fallback to default feed URLs - Add design documentation in docs/UPDATE_CONFIG_DESIGN.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(i18n): Auto update translations for PR #11147 * format code * 🔧 chore: update config for v1.7.5 → v2.0.0 → v2.1.6 upgrade path Update version configuration to support multi-step upgrade path: - v1.6.x users → v1.7.5 (last v1.x release) - v1.7.x users → v2.0.0 (v2.x intermediate version) - v2.0.0+ users → v2.1.6 (current latest) Changes: - Update 1.7.0 → 1.7.5 with fixed feedUrl - Set 2.0.0 as intermediate version with fixed feedUrl - Add 2.1.6 as current latest pointing to releases/latest This ensures users upgrade through required intermediate versions before jumping to major releases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * 🔧 chore: refactor update config with constants and adjust versions Refactor update configuration system and adjust to actual versions: - Add UpdateConfigUrl enum in constant.ts for centralized config URLs - Point to test server (birdcat.top) for development testing - Update AppUpdater.ts to use UpdateConfigUrl constants - Adjust update-config.json to actual v1.6.7 with rc/beta channels - Remove v2.1.6 entry (not yet released) - Set package version to 1.6.5 for testing upgrade path - Add update-config.example.json for reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update version * ✅ test: add comprehensive unit tests for AppUpdater config system Add extensive test coverage for new config-based update system including: - Config fetching with IP-based source selection (GitHub/GitCode) - Channel compatibility matching with version constraints - Smart fallback from rc/beta to latest when appropriate - Multi-step upgrade path validation (1.6.3 → 1.6.7 → 2.0.0) - Error handling for network and HTTP failures Test Coverage: - _fetchUpdateConfig: 4 tests (GitHub/GitCode selection, error handling) - _findCompatibleChannel: 9 tests (channel matching, version comparison) - Upgrade Path: 3 tests (version gating scenarios) - Total: 30 tests, 100% passing Also optimize _findCompatibleChannel logic with better variable naming and log messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * ✅ test: add complete multi-step upgrade path tests (1.6.3 → 1.7.5 → 2.0.0 → 2.1.6) Add comprehensive test suite for complete upgrade journey including: - Individual step validation (1.6.3→1.7.5, 1.7.5→2.0.0, 2.0.0→2.1.6) - Full multi-step upgrade simulation with version progression - Version gating enforcement (block skipping intermediate versions) - Verification that 1.6.3 cannot directly upgrade to 2.0.0 or 2.1.6 - Verification that 1.7.5 cannot skip 2.0.0 to reach 2.1.6 Test Coverage: - 6 new tests for complete upgrade path scenarios - Total: 36 tests, 100% passing This ensures the version compatibility system correctly enforces intermediate version upgrades for major releases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * 📝 docs: reorganize update config documentation with English translation Move update configuration design document to docs/technical/ directory and add English translation for international contributors. Changes: - Move docs/UPDATE_CONFIG_DESIGN.md → docs/technical/app-update-config-zh.md - Add docs/technical/app-update-config-en.md (English translation) - Organize technical documentation in dedicated directory Documentation covers: - Config-based update system design and rationale - JSON schema with version compatibility control - Multi-step upgrade path examples (1.6.3 → 1.7.5 → 2.0.0 → 2.1.6) - TypeScript type definitions and matching algorithms - GitHub/GitCode source selection for different regions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * format code * ✅ test: add tests for latest channel self-comparison prevention Add tests to verify the optimization that prevents comparing latest channel with itself when latest is requested, and ensures rc/beta channels are returned when they are newer than latest. New tests: - should not compare latest with itself when requesting latest channel - should return rc when rc version > latest version - should return beta when beta version > latest version These tests ensure the requestedChannel !== UpgradeChannel.LATEST check works correctly and users get the right channel based on version comparisons. Test Coverage: 39 tests, 100% passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update github/gitcode * format code * update rc version * ♻️ refactor: merge update configs into single multi-mirror file - Merge app-upgrade-config-github.json and app-upgrade-config-gitcode.json into single app-upgrade-config.json - Add UpdateMirror enum for type-safe mirror selection - Optimize _fetchUpdateConfig to receive mirror parameter, eliminating duplicate IP country checks - Update ChannelConfig interface to use Record<UpdateMirror, string> for feedUrls - Rename documentation files from app-update-config-* to app-upgrade-config-* - Update docs with new multi-mirror configuration structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * ✅ test: update AppUpdater tests for multi-mirror configuration - Add UpdateMirror enum import - Update _fetchUpdateConfig tests to accept mirror parameter - Convert all feedUrl to feedUrls structure in test mocks - Update test expectations to match new ChannelConfig interface - All 39 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * format code * delete files * 📝 docs: add UpdateMirror enum to type definitions - Add UpdateMirror enum definition in both EN and ZH docs - Update ChannelConfig to use Record<UpdateMirror, string> - Add comments showing equivalent structure for clarity 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * 🐛 fix: return actual channel from _findCompatibleChannel Fix channel mismatch issue where requesting rc/beta but getting latest: - Change _findCompatibleChannel return type to include actual channel - Return { config, channel } instead of just config - Update _setFeedUrl to use actualChannel instead of requestedChannel - Update all test expectations to match new return structure - Add channel assertions to key tests This ensures autoUpdater.channel matches the actual feed URL being used. Fixes issue where: - User requests 'rc' channel - latest >= rc, so latest config is returned - But channel was set to 'rc' with latest URL ❌ - Now channel is correctly set to 'latest' ✅ All 39 tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update version * udpate version * update config * add no cache header * update files * 🤖 chore: automate app upgrade config updates * format code * update workflow * update get method * docs: document upgrade workflow automation --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com>
533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import semver from 'semver'
|
|
|
|
type UpgradeChannel = 'latest' | 'rc' | 'beta'
|
|
type UpdateMirror = 'github' | 'gitcode'
|
|
|
|
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
|
|
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
|
|
const GITHUB_REPO = 'CherryHQ/cherry-studio'
|
|
const GITCODE_REPO = 'CherryHQ/cherry-studio'
|
|
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
|
|
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
|
|
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
|
|
}
|
|
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
|
|
|
|
interface CliOptions {
|
|
tag?: string
|
|
configPath?: string
|
|
segmentsPath?: string
|
|
dryRun?: boolean
|
|
skipReleaseChecks?: boolean
|
|
isPrerelease?: boolean
|
|
}
|
|
|
|
interface ChannelTemplateConfig {
|
|
feedTemplates?: Partial<Record<UpdateMirror, string>>
|
|
}
|
|
|
|
interface SegmentMatchRule {
|
|
range?: string
|
|
exact?: string[]
|
|
excludeExact?: string[]
|
|
}
|
|
|
|
interface SegmentDefinition {
|
|
id: string
|
|
type: 'legacy' | 'breaking' | 'latest'
|
|
match: SegmentMatchRule
|
|
lockedVersion?: string
|
|
minCompatibleVersion: string
|
|
description: string
|
|
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
|
|
}
|
|
|
|
interface SegmentMetadataFile {
|
|
segments: SegmentDefinition[]
|
|
}
|
|
|
|
interface ChannelConfig {
|
|
version: string
|
|
feedUrls: Record<UpdateMirror, string>
|
|
}
|
|
|
|
interface VersionMetadata {
|
|
segmentId: string
|
|
segmentType?: string
|
|
}
|
|
|
|
interface VersionEntry {
|
|
metadata?: VersionMetadata
|
|
minCompatibleVersion: string
|
|
description: string
|
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
|
}
|
|
|
|
interface UpgradeConfigFile {
|
|
lastUpdated: string
|
|
versions: Record<string, VersionEntry>
|
|
}
|
|
|
|
interface ReleaseInfo {
|
|
tag: string
|
|
version: string
|
|
channel: UpgradeChannel
|
|
}
|
|
|
|
interface UpdateVersionsResult {
|
|
versions: Record<string, VersionEntry>
|
|
updated: boolean
|
|
}
|
|
|
|
const ROOT_DIR = path.resolve(__dirname, '..')
|
|
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
|
|
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
|
|
|
|
async function main() {
|
|
const options = parseArgs()
|
|
const releaseTag = resolveTag(options)
|
|
const normalizedVersion = normalizeVersion(releaseTag)
|
|
const releaseChannel = detectChannel(normalizedVersion)
|
|
if (!releaseChannel) {
|
|
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
|
|
return
|
|
}
|
|
|
|
// Validate version format matches prerelease status
|
|
if (options.isPrerelease !== undefined) {
|
|
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
|
|
|
|
if (options.isPrerelease && !hasPrereleaseSuffix) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
|
|
)
|
|
return
|
|
}
|
|
|
|
if (!options.isPrerelease && hasPrereleaseSuffix) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
const [config, segmentFile] = await Promise.all([
|
|
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
|
|
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
|
|
])
|
|
|
|
const segment = pickSegment(segmentFile.segments, normalizedVersion)
|
|
if (!segment) {
|
|
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
|
|
}
|
|
|
|
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
|
|
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
|
|
}
|
|
|
|
const releaseInfo: ReleaseInfo = {
|
|
tag: formatTag(releaseTag),
|
|
version: normalizedVersion,
|
|
channel: releaseChannel
|
|
}
|
|
|
|
const { versions: updatedVersions, updated } = await updateVersions(
|
|
config.versions,
|
|
segment,
|
|
releaseInfo,
|
|
Boolean(options.skipReleaseChecks)
|
|
)
|
|
|
|
if (!updated) {
|
|
throw new Error(
|
|
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
|
|
)
|
|
}
|
|
|
|
const updatedConfig: UpgradeConfigFile = {
|
|
...config,
|
|
lastUpdated: new Date().toISOString(),
|
|
versions: updatedVersions
|
|
}
|
|
|
|
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
|
|
|
|
if (options.dryRun) {
|
|
console.log('Dry run enabled. Generated configuration:\n')
|
|
console.log(output)
|
|
return
|
|
}
|
|
|
|
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
|
|
console.log(
|
|
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
|
|
)
|
|
}
|
|
|
|
function parseArgs(): CliOptions {
|
|
const args = process.argv.slice(2)
|
|
const options: CliOptions = {}
|
|
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
const arg = args[i]
|
|
if (arg === '--tag') {
|
|
options.tag = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--config') {
|
|
options.configPath = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--segments') {
|
|
options.segmentsPath = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--dry-run') {
|
|
options.dryRun = true
|
|
} else if (arg === '--skip-release-checks') {
|
|
options.skipReleaseChecks = true
|
|
} else if (arg === '--is-prerelease') {
|
|
options.isPrerelease = args[i + 1] === 'true'
|
|
i += 1
|
|
} else if (arg === '--help') {
|
|
printHelp()
|
|
process.exit(0)
|
|
} else {
|
|
console.warn(`Ignoring unknown argument "${arg}"`)
|
|
}
|
|
}
|
|
|
|
if (options.skipReleaseChecks && !options.dryRun) {
|
|
throw new Error('--skip-release-checks can only be used together with --dry-run')
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
|
|
|
|
Options:
|
|
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
|
|
--config <path> Path to app-upgrade-config.json.
|
|
--segments <path> Path to app-upgrade-segments.json.
|
|
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
|
|
--dry-run Print the result without writing to disk.
|
|
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
|
|
--help Show this help message.`)
|
|
}
|
|
|
|
function resolveTag(options: CliOptions): string {
|
|
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
|
|
const tag = options.tag ?? envTag
|
|
|
|
if (!tag) {
|
|
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
|
|
}
|
|
|
|
return tag
|
|
}
|
|
|
|
function normalizeVersion(tag: string): string {
|
|
const cleaned = semver.clean(tag, { loose: true })
|
|
if (!cleaned) {
|
|
throw new Error(`Tag "${tag}" is not a valid semantic version`)
|
|
}
|
|
|
|
const valid = semver.valid(cleaned, { loose: true })
|
|
if (!valid) {
|
|
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
|
|
}
|
|
|
|
return valid
|
|
}
|
|
|
|
function detectChannel(version: string): UpgradeChannel | null {
|
|
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
|
if (!parsed) {
|
|
return null
|
|
}
|
|
|
|
if (parsed.prerelease.length === 0) {
|
|
return 'latest'
|
|
}
|
|
|
|
const label = String(parsed.prerelease[0]).toLowerCase()
|
|
if (label === 'beta') {
|
|
return 'beta'
|
|
}
|
|
if (label === 'rc') {
|
|
return 'rc'
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function readJson<T>(filePath: string): Promise<T> {
|
|
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
|
const data = await fs.readFile(absolute, 'utf-8')
|
|
return JSON.parse(data) as T
|
|
}
|
|
|
|
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
|
|
for (const segment of segments) {
|
|
if (matchesSegment(segment.match, version)) {
|
|
return segment
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
|
|
if (matchRule.exact && matchRule.exact.includes(version)) {
|
|
return true
|
|
}
|
|
|
|
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
|
|
return false
|
|
}
|
|
|
|
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
|
|
return false
|
|
}
|
|
|
|
if (matchRule.exact) {
|
|
return matchRule.exact.includes(version)
|
|
}
|
|
|
|
return Boolean(matchRule.range)
|
|
}
|
|
|
|
function formatTag(tag: string): string {
|
|
if (tag.startsWith('refs/tags/')) {
|
|
return tag.replace('refs/tags/', '')
|
|
}
|
|
return tag
|
|
}
|
|
|
|
async function updateVersions(
|
|
versions: Record<string, VersionEntry>,
|
|
segment: SegmentDefinition,
|
|
releaseInfo: ReleaseInfo,
|
|
skipReleaseValidation: boolean
|
|
): Promise<UpdateVersionsResult> {
|
|
const versionsCopy: Record<string, VersionEntry> = { ...versions }
|
|
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
|
|
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
|
|
const shouldRename = existingKey && existingKey !== targetKey
|
|
|
|
let entry: VersionEntry
|
|
if (existingKey) {
|
|
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
|
|
} else {
|
|
entry = createEmptyVersionEntry()
|
|
}
|
|
|
|
entry.channels = ensureChannelSlots(entry.channels)
|
|
|
|
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
|
|
if (!channelUpdated) {
|
|
return { versions, updated: false }
|
|
}
|
|
|
|
if (shouldRename && existingKey) {
|
|
delete versionsCopy[existingKey]
|
|
}
|
|
|
|
entry.metadata = {
|
|
segmentId: segment.id,
|
|
segmentType: segment.type
|
|
}
|
|
entry.minCompatibleVersion = segment.minCompatibleVersion
|
|
entry.description = segment.description
|
|
|
|
versionsCopy[targetKey] = entry
|
|
return {
|
|
versions: sortVersionMap(versionsCopy),
|
|
updated: true
|
|
}
|
|
}
|
|
|
|
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
|
|
for (const [key, value] of Object.entries(versions)) {
|
|
if (value.metadata?.segmentId === segmentId) {
|
|
return key
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
|
|
if (segment.lockedVersion) {
|
|
return segment.lockedVersion
|
|
}
|
|
|
|
if (releaseInfo.channel === 'latest') {
|
|
return releaseInfo.version
|
|
}
|
|
|
|
if (existingKey) {
|
|
return existingKey
|
|
}
|
|
|
|
const baseVersion = getBaseVersion(releaseInfo.version)
|
|
return baseVersion ?? releaseInfo.version
|
|
}
|
|
|
|
function getBaseVersion(version: string): string | null {
|
|
const parsed = semver.parse(version, { loose: true, includePrerelease: true })
|
|
if (!parsed) {
|
|
return null
|
|
}
|
|
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
|
|
}
|
|
|
|
function createEmptyVersionEntry(): VersionEntry {
|
|
return {
|
|
minCompatibleVersion: '',
|
|
description: '',
|
|
channels: {
|
|
latest: null,
|
|
rc: null,
|
|
beta: null
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureChannelSlots(
|
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
|
): Record<UpgradeChannel, ChannelConfig | null> {
|
|
return CHANNELS.reduce(
|
|
(acc, channel) => {
|
|
acc[channel] = channels[channel] ?? null
|
|
return acc
|
|
},
|
|
{} as Record<UpgradeChannel, ChannelConfig | null>
|
|
)
|
|
}
|
|
|
|
async function applyChannelUpdate(
|
|
entry: VersionEntry,
|
|
segment: SegmentDefinition,
|
|
releaseInfo: ReleaseInfo,
|
|
skipReleaseValidation: boolean
|
|
): Promise<boolean> {
|
|
if (!CHANNELS.includes(releaseInfo.channel)) {
|
|
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
|
|
}
|
|
|
|
const feedUrls = buildFeedUrls(segment, releaseInfo)
|
|
|
|
if (skipReleaseValidation) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
|
|
)
|
|
} else {
|
|
const availability = await ensureReleaseAvailability(releaseInfo)
|
|
if (!availability.github) {
|
|
return false
|
|
}
|
|
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
|
|
)
|
|
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
|
|
}
|
|
}
|
|
|
|
entry.channels[releaseInfo.channel] = {
|
|
version: releaseInfo.version,
|
|
feedUrls
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
|
|
return MIRRORS.reduce(
|
|
(acc, mirror) => {
|
|
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
|
|
acc[mirror] = applyTemplate(template, releaseInfo)
|
|
return acc
|
|
},
|
|
{} as Record<UpdateMirror, string>
|
|
)
|
|
}
|
|
|
|
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
|
|
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
|
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
|
|
}
|
|
|
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
|
|
}
|
|
|
|
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
|
|
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
|
|
}
|
|
|
|
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
|
|
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
|
|
return sorted.reduce(
|
|
(acc, [version, entry]) => {
|
|
acc[version] = entry
|
|
return acc
|
|
},
|
|
{} as Record<string, VersionEntry>
|
|
)
|
|
}
|
|
|
|
interface ReleaseAvailability {
|
|
github: boolean
|
|
gitcode: boolean
|
|
}
|
|
|
|
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
|
|
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
|
|
const availability: ReleaseAvailability = {
|
|
github: false,
|
|
gitcode: releaseInfo.channel === 'latest' ? false : true
|
|
}
|
|
|
|
for (const mirror of mirrorsToCheck) {
|
|
const url = getReleasePageUrl(mirror, releaseInfo.tag)
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: mirror === 'github' ? 'HEAD' : 'GET',
|
|
redirect: 'follow'
|
|
})
|
|
|
|
if (response.ok) {
|
|
availability[mirror] = true
|
|
} else {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
|
|
)
|
|
availability[mirror] = false
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
|
|
error
|
|
)
|
|
availability[mirror] = false
|
|
}
|
|
}
|
|
|
|
return availability
|
|
}
|
|
|
|
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
|
|
if (mirror === 'github') {
|
|
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
|
|
}
|
|
// Use latest.yml download URL for GitCode to check if release exists
|
|
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
|
|
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('❌ Failed to update app-upgrade-config:', error)
|
|
process.exit(1)
|
|
})
|