/** * AGC 总版本号(发号源)。 * * 唯一事实源是 OSS 对象 `agc/global-version.json`;渠道清单只写各自本次拿到的号。 * 仓库里的 5 个版本文件仍由构建改写,但只作构建输入参考,不作为事实源。 * * 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」:任何一步失败都不回滚, * 只烧号。这样渠道之间不会复用同一个号,代价是可能出现空洞。 */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export const AGC_GLOBAL_VERSION_OBJECT_KEY = 'agc/global-version.json'; const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; const versionPattern = /^\d+\.\d+\.\d+$/u; function trimTrailingSlashes(value) { return value.replace(/\/+$/u, ''); } export function ossBaseUrl(env = process.env) { return trimTrailingSlashes( env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl, ); } export function globalVersionUrl(env = process.env) { return `${ossBaseUrl(env)}/global-version.json`; } export function parseVersion(value, label) { if (typeof value !== 'string' || !versionPattern.test(value.trim())) { throw new Error(`${label} 不是有效的三段版本号:${String(value)}`); } return value.trim(); } export function compareVersions(left, right) { const leftParts = parseVersion(left, '左版本').split('.').map(Number); const rightParts = parseVersion(right, '右版本').split('.').map(Number); for (let index = 0; index < 3; index += 1) { if (leftParts[index] !== rightParts[index]) { return leftParts[index] > rightParts[index] ? 1 : -1; } } return 0; } /** 取较大版本;任一为空时返回另一个。 */ export function maxVersion(...versions) { return versions .filter((value) => typeof value === 'string' && value.trim()) .map((value) => parseVersion(value, '候选版本')) .reduce( (best, current) => best == null || compareVersions(current, best) > 0 ? current : best, null, ); } export function nextVersion(current) { const [major, minor, patch] = parseVersion(current, '总版本') .split('.') .map(Number); if (patch === Number.MAX_SAFE_INTEGER) { throw new Error(`版本号 patch 已达到上限:${current}`); } return `${major}.${minor}.${patch + 1}`; } export function readReleaseDryRun(env = process.env) { const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase(); return value === '1' || value === 'true'; } async function fetchJson(url, label, { fetchImpl = fetch } = {}) { let response; try { response = await fetchImpl(url, { headers: { Accept: 'application/json' }, }); } catch (error) { throw new Error(`读取 ${label} 失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } try { return await response.json(); } catch (error) { throw new Error(`${label} 不是有效 JSON:${error.message}`); } } /** 渠道清单版本;缺失或 404 时返回 null(首次启用渠道)。 */ export async function readChannelVersion(channel, options = {}) { const payload = await fetchJson( `${ossBaseUrl(options.env)}/${channel}/latest.json`, `${channel} 渠道清单`, options, ); if (payload == null) return null; const version = typeof payload.version === 'string' ? payload.version : ''; if (!version) { throw new Error(`${channel} 渠道清单缺少 version 字段`); } return parseVersion(version, `${channel} 渠道清单 version`); } /** 旧协议迁移指针 `agc/latest.json`;只在迁移窗口内存在,仅参与播种。 */ export async function readLegacyPointerVersion(options = {}) { const payload = await fetchJson( `${ossBaseUrl(options.env)}/latest.json`, 'OSS 迁移指针', options, ); if (payload == null) return null; const version = typeof payload.version === 'string' ? payload.version : ''; return version ? parseVersion(version, 'OSS 迁移指针 version') : null; } export async function readGlobalVersion(options = {}) { const payload = await fetchJson( globalVersionUrl(options.env), 'AGC 总版本号', options, ); if (payload == null) return null; const version = typeof payload.version === 'string' ? payload.version : ''; if (!version) { throw new Error('AGC 总版本号对象缺少 version 字段'); } return { ...payload, version: parseVersion(version, 'AGC 总版本号 version'), }; } /** * 一次性播种基线:仓库当前版本、渠道清单与旧迁移指针里的最大值。 * 基线本身不发给客户端,首个发放号是 baseline + 1。 */ export async function resolveSeedBaseline({ channels = ['dev-win', 'dev-mac'], repoVersion = null, env = process.env, fetchImpl = fetch, } = {}) { const candidates = []; if (repoVersion) candidates.push(parseVersion(repoVersion, '仓库当前版本')); for (const channel of channels) { const version = await readChannelVersion(channel, { env, fetchImpl }); if (version) candidates.push(version); } const legacy = await readLegacyPointerVersion({ env, fetchImpl }); if (legacy) candidates.push(legacy); const baseline = maxVersion(...candidates); if (!baseline) { throw new Error('无法确定总版本号播种基线:仓库版本与渠道清单都不可用'); } return baseline; } /** * 组装 ossutil 参数。 * * 该桶与凭据按 v1 签名使用(ossutil v2 默认 v4,缺 region 会直接失败), * 因此默认显式传 `--sign-version v1`;需要 v4 时用 `AGC_OSS_SIGN_VERSION=v4` * 并同时给 `AGC_OSS_REGION`。 */ export function buildOssutilArgs({ args, endpoint, accessKeyId, accessKeySecret, env = process.env, }) { const finalArgs = [...args, '--endpoint', endpoint]; const region = env.AGC_OSS_REGION?.trim(); if (region) finalArgs.push('--region', region); finalArgs.push('--sign-version', env.AGC_OSS_SIGN_VERSION?.trim() || 'v1'); if (accessKeyId) { finalArgs.push( '--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret, ); } return finalArgs; } function runOssutil(args, { env = process.env } = {}) { const binary = env.OSSUTIL_BIN?.trim() || 'ossutil'; const endpoint = env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; const accessKeyId = env.AGC_OSS_ACCESS_KEY_ID?.trim(); const accessKeySecret = env.AGC_OSS_ACCESS_KEY_SECRET; if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); } const result = spawnSync( binary, buildOssutilArgs({ args, endpoint, accessKeyId, accessKeySecret, env, }), { stdio: 'inherit', shell: false, env }, ); if (result.error) { throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`); } if (result.status !== 0) { throw new Error(`${binary} 执行失败,退出码 ${result.status}`); } } /** 写入总版本号对象;dry-run 下只打印将要执行的上传。 */ export function writeGlobalVersion(payload, options = {}) { const { env = process.env, dryRun = readReleaseDryRun(env) } = options; const bucket = env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const body = Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8'); const tempDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'agc-global-version-'), ); const tempPath = path.join(tempDirectory, 'global-version.json'); try { fs.writeFileSync(tempPath, body); const ossUrl = `oss://${bucket}/${AGC_GLOBAL_VERSION_OBJECT_KEY}`; if (dryRun) { console.log( `[dry-run] 不写总版本号:${ossUrl} <- ${JSON.stringify(payload)}`, ); return { written: false, objectUrl: ossUrl, payload }; } runOssutil(['cp', '--force', tempPath, ossUrl], { env }); return { written: true, objectUrl: ossUrl, payload }; } finally { fs.rmSync(tempDirectory, { force: true, recursive: true }); } } /** * 发一次号并写回总版本号对象。 * * 写后回读校验:若远端值与自己写下的不一致,说明有并发发号,按失败关闭处理 * (号已烧,不重试、不回滚),由人工确认后再发。 */ export async function issueGlobalVersion({ channel, commit = null, buildId = null, repoVersion = null, env = process.env, fetchImpl = fetch, now = () => new Date().toISOString(), writeImpl = writeGlobalVersion, } = {}) { if (!channel) throw new Error('发号必须显式指定 channel'); const dryRun = readReleaseDryRun(env); const current = await readGlobalVersion({ env, fetchImpl }); let baseline = current?.version ?? null; let seeded = false; if (!baseline) { baseline = await resolveSeedBaseline({ repoVersion, env, fetchImpl }); seeded = true; } const issued = nextVersion(baseline); const payload = { version: issued, updatedAt: now(), channel, commit, buildId, }; console.log( `[agc-global-version] ${ seeded ? `按播种基线 ${baseline} 首发` : `总号 ${baseline}` } -> ${issued}(channel=${channel} dry-run=${dryRun})`, ); writeImpl(payload, { env, dryRun }); if (!dryRun) { const stored = await readGlobalVersion({ env, fetchImpl }); if (!stored || stored.version !== issued) { throw new Error( `总版本号写后回读不一致:期望 ${issued},远端 ${ stored?.version ?? '不存在' };可能存在并发发号,本次构建失败关闭`, ); } } return issued; } /** 渠道高水位断言:请求号低于本渠道清单版本即失败关闭。 */ export function assertRequestedVersionNotBelowChannel({ requested, channelVersion, channel, }) { const requestedVersion = parseVersion(requested, '请求版本'); if (channelVersion == null) return requestedVersion; const current = parseVersion(channelVersion, `${channel} 渠道版本`); if (compareVersions(requestedVersion, current) < 0) { throw new Error( `请求版本 ${requestedVersion} 低于 ${channel} 渠道当前清单版本 ${current};拒绝回退发布`, ); } return requestedVersion; } /** 只读预览:不写回、不烧号。 */ export async function previewNextGlobalVersion(options = {}) { const current = await readGlobalVersion(options); const baseline = current?.version ?? (await resolveSeedBaseline({ repoVersion: options.repoVersion, env: options.env, fetchImpl: options.fetchImpl, })); return nextVersion(baseline); }