39aed2e486
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 19s
Project CI / AI game creator shell Rust crates (push) Failing after 18s
Project CI / Native shell tests (push) Failing after 18s
Project CI / Backend tests (push) Failing after 18s
Project CI / AI game creator shell Rust smoke (push) Failing after 19s
Project CI / Frontend tests (push) Failing after 7s
Project CI / AI game creator shell web tests (push) Failing after 11s
Project CI / Repository checks (push) Failing after 11s
ossutil v2 默认 v4 签名,缺 region 会直接失败\n总号写入与回读统一走 buildOssutilArgs,默认 --sign-version v1,可切 v4 并配合 AGC_OSS_REGION\n补充参数构造单测
331 lines
11 KiB
JavaScript
331 lines
11 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|