AGC 客户端版本号收敛为单一发号源
Project CI / AI game creator shell Rust shard 2/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 1/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 18s
Project CI / Backend tests (push) Failing after 18s
Project CI / Native shell tests (push) Failing after 18s
Project CI / AI game creator shell Rust crates (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 12s
Project CI / Repository checks (push) Failing after 12s

新增 OSS agc/global-version.json 发号模块与发号入口脚本\n构建侧优先采用发号 Job 下发的总号,未传入时本地兜底发号\n渠道高水位降级为回退断言,请求号低于渠道清单版本即失败关闭\n新增 Genarrative-Agc-Global-Version-Issue 发号 Job 与 job config\n调度管线与手动管线先发号再透传 AGC_RELEASE_VERSION\n补充单元测试、生产运维门禁、主规范与里程碑文档
This commit is contained in:
2026-09-20 18:12:49 +08:00
parent 4864e92e30
commit 9e0cbc7041
14 changed files with 1021 additions and 9 deletions
@@ -0,0 +1,298 @@
/**
* 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;
}
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 credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawnSync(
binary,
[...args, '--endpoint', endpoint, ...credentialArgs],
{ 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);
}
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
assertRequestedVersionNotBelowChannel,
issueGlobalVersion,
maxVersion,
nextVersion,
previewNextGlobalVersion,
resolveSeedBaseline,
} from './agc-global-version.mjs';
function jsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
};
}
/** 以 URL 为键的假 OSS:只读 fetch + 记录写入。 */
function createFakeOss({ objects = {} } = {}) {
const state = { ...objects };
const writes = [];
return {
state,
writes,
fetchImpl: async (url) => {
const key = String(url).replace(/^https?:\/\/[^/]+\//u, '');
if (!(key in state)) return jsonResponse(null, 404);
return jsonResponse(state[key]);
},
writeImpl: (payload, options = {}) => {
writes.push({ payload, dryRun: Boolean(options.dryRun) });
if (!options.dryRun) state['agc/global-version.json'] = payload;
return { written: !options.dryRun, payload };
},
};
}
test('播种基线取仓库版本、各渠道清单与旧迁移指针的最大值', async () => {
const oss = createFakeOss({
objects: {
'agc/dev-win/latest.json': { version: '0.1.57' },
'agc/dev-mac/latest.json': { version: '0.1.12' },
'agc/latest.json': { version: '0.1.60' },
},
});
assert.equal(
await resolveSeedBaseline({
repoVersion: '0.1.48',
env: {},
fetchImpl: oss.fetchImpl,
}),
'0.1.60',
);
assert.equal(maxVersion('0.1.9', '0.1.10', null), '0.1.10');
});
test('无总号时按播种基线发首号,并把总号写回唯一事实源', async () => {
const oss = createFakeOss({
objects: {
'agc/dev-win/latest.json': { version: '0.1.57' },
'agc/dev-mac/latest.json': { version: '0.1.12' },
},
});
const issued = await issueGlobalVersion({
channel: 'dev-win',
commit: 'a'.repeat(40),
buildId: '319',
repoVersion: '0.1.48',
env: {},
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
now: () => '2026-09-20T00:00:00.000Z',
});
assert.equal(issued, '0.1.58');
assert.deepEqual(
oss.writes.map((entry) => entry.payload.version),
['0.1.58'],
);
assert.equal(oss.state['agc/global-version.json'].version, '0.1.58');
assert.equal(oss.state['agc/global-version.json'].channel, 'dev-win');
assert.equal(oss.state['agc/global-version.json'].buildId, '319');
});
test('已有总号时只递增,不再回看渠道清单', async () => {
const oss = createFakeOss({
objects: {
'agc/global-version.json': { version: '0.2.7' },
// 渠道清单被手工改小也不能把总号拉回去。
'agc/dev-win/latest.json': { version: '0.1.10' },
},
});
const issued = await issueGlobalVersion({
channel: 'dev-mac',
env: {},
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
});
assert.equal(issued, '0.2.8');
assert.equal(oss.state['agc/global-version.json'].version, '0.2.8');
});
test('dry-run 只预览下一位,不写回、不烧号', async () => {
const oss = createFakeOss({
objects: { 'agc/global-version.json': { version: '0.3.4' } },
});
const preview = await previewNextGlobalVersion({
env: {},
fetchImpl: oss.fetchImpl,
});
assert.equal(preview, '0.3.5');
assert.equal(oss.writes.length, 0);
const issued = await issueGlobalVersion({
channel: 'dev-win',
env: { AGC_RELEASE_DRY_RUN: '1' },
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
});
assert.equal(issued, '0.3.5');
assert.deepEqual(
oss.writes.map((entry) => entry.dryRun),
[true],
);
assert.equal(oss.state['agc/global-version.json'].version, '0.3.4');
});
test('传入低于本渠道清单的号时失败关闭', () => {
assert.equal(
assertRequestedVersionNotBelowChannel({
requested: '0.1.60',
channelVersion: '0.1.60',
channel: 'dev-win',
}),
'0.1.60',
);
assert.throws(
() =>
assertRequestedVersionNotBelowChannel({
requested: '0.1.59',
channelVersion: '0.1.60',
channel: 'dev-win',
}),
/低于 dev-win 渠道当前清单版本/u,
);
assert.equal(
assertRequestedVersionNotBelowChannel({
requested: '0.1.1',
channelVersion: null,
channel: 'dev-mac',
}),
'0.1.1',
);
});
test('写后回读不一致(并发发号)时失败关闭', async () => {
const oss = createFakeOss({
objects: { 'agc/global-version.json': { version: '0.5.1' } },
});
await assert.rejects(
issueGlobalVersion({
channel: 'dev-win',
env: {},
// 模拟另一个发号进程在写入后覆盖了总号。
writeImpl: (payload, options) => {
const result = oss.writeImpl(payload, options);
// 另一个发号进程紧随其后覆盖总号。
oss.state['agc/global-version.json'] = { version: '0.5.9' };
return result;
},
fetchImpl: oss.fetchImpl,
}),
/写后回读不一致/u,
);
});
test('nextVersion 只在 patch 位递增', () => {
assert.equal(nextVersion('0.1.9'), '0.1.10');
assert.equal(nextVersion('1.0.0'), '1.0.1');
assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u);
});
@@ -5,6 +5,10 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
assertRequestedVersionNotBelowChannel,
issueGlobalVersion as issueAgcGlobalVersion,
} from './agc-global-version.mjs';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
@@ -312,14 +316,35 @@ function replaceVersionLine(source, version, pattern, label) {
return source.replace(pattern, `$1${version}$3`);
}
/**
* 版本来源固定为 OSS 总版本号(`agc/global-version.json`):
* - CI 统一构建由发号 Job 先发号,再通过 AGC_RELEASE_VERSION 透传给各渠道;
* - 未传入时(本地手工兜底)由本函数现场发号并写回总号;
* - 渠道高水位只做断言:传入号低于本渠道清单版本即失败关闭。
*/
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
const { channel, target } = context;
const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
: nextPatchVersion(localVersion, remoteVersion);
? assertRequestedVersionNotBelowChannel({
requested: requestedVersion,
channelVersion: remoteVersion,
channel,
})
: await issueAgcGlobalVersion({
channel,
commit:
process.env.COMMIT_HASH?.trim() ||
process.env.GIT_COMMIT?.trim() ||
null,
buildId:
process.env.BUILD_NUMBER?.trim() ||
process.env.AGC_BUILD_ID?.trim() ||
null,
repoVersion: localVersion,
});
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
fs.writeFileSync(
@@ -378,8 +403,8 @@ export async function prepareReleaseVersion(context = resolveReleaseContext()) {
console.log(
requestedVersion
? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本${localVersion} / OSS ${remoteVersion ?? '不存在'}`
: `[ai-game-creator-shell] 渠道 ${channel} ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
? `[ai-game-creator-shell] 渠道 ${channel} 使用发号 Job 下发的总号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion}`
: `[ai-game-creator-shell] 渠道 ${channel}地兜底发号 ${nextVersion}(本渠道清单 ${remoteVersion ?? '不存在'} / 仓库 ${localVersion}`,
);
return nextVersion;
}
@@ -0,0 +1,121 @@
/**
* AGC 总版本号发号入口(CI 发号 Job 与本地手工兜底共用)。
*
* 用法:
* node scripts/issue-global-version.mjs --channel dev-win [--commit <sha>] [--build-id <id>] [--out <file>]
* node scripts/issue-global-version.mjs --seed-only
* node scripts/issue-global-version.mjs --dry-run --channel dev-win # 只预览,不写回、不烧号
*
* 输出固定为一行 `AGC_GLOBAL_VERSION=<version>`,便于 Jenkins 直接读取。
*/
import fs from 'node:fs';
import {
issueGlobalVersion,
previewNextGlobalVersion,
readGlobalVersion,
readReleaseDryRun,
resolveSeedBaseline,
writeGlobalVersion,
} from './agc-global-version.mjs';
function parseArgs(argv) {
const options = {
channel: '',
commit: '',
buildId: '',
repoVersion: '',
out: '',
seedOnly: false,
dryRun: readReleaseDryRun(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (label) => {
const value = argv[index + 1];
if (value == null || value.startsWith('--')) {
throw new Error(`${label} 缺少取值`);
}
index += 1;
return value;
};
switch (arg) {
case '--channel':
options.channel = readValue('--channel');
break;
case '--commit':
options.commit = readValue('--commit');
break;
case '--build-id':
options.buildId = readValue('--build-id');
break;
case '--repo-version':
options.repoVersion = readValue('--repo-version');
break;
case '--out':
options.out = readValue('--out');
break;
case '--seed-only':
options.seedOnly = true;
break;
case '--dry-run':
options.dryRun = true;
break;
default:
throw new Error(`未知参数:${arg}`);
}
}
return options;
}
function emit(version, options) {
console.log(`AGC_GLOBAL_VERSION=${version}`);
if (options.out) {
fs.writeFileSync(options.out, `${version}\n`, 'utf8');
}
}
const options = parseArgs(process.argv.slice(2));
process.env.AGC_RELEASE_DRY_RUN = options.dryRun ? '1' : '0';
if (options.seedOnly) {
const current = await readGlobalVersion();
if (current) {
console.log(
`[agc-global-version] 总号已存在(${current.version}),播种跳过;需要重新播种请先人工确认`,
);
emit(current.version, options);
} else {
const baseline = await resolveSeedBaseline({
repoVersion: options.repoVersion || null,
});
const payload = {
version: baseline,
updatedAt: new Date().toISOString(),
channel: options.channel || 'seed',
commit: options.commit || null,
buildId: options.buildId || null,
};
writeGlobalVersion(payload, { dryRun: options.dryRun });
console.log(
`[agc-global-version] 播种基线 ${baseline}(尚未发号,首发为下一位)`,
);
emit(baseline, options);
}
} else if (options.dryRun) {
const preview = await previewNextGlobalVersion({
repoVersion: options.repoVersion || null,
});
console.log(
`[agc-global-version] 预览下一个总号 ${preview}dry-run 不写回、不烧号)`,
);
emit(preview, options);
} else {
const issued = await issueGlobalVersion({
channel: options.channel || 'manual',
commit: options.commit || null,
buildId: options.buildId || null,
repoVersion: options.repoVersion || null,
});
emit(issued, options);
}
+1
View File
@@ -44,6 +44,7 @@
- [AGC Godot 编辑器插件接入](<./technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)GDExtension 聚焦加载、安装资源、受管描述文件、UID 归属、GDScript 回执与 Runner 边界。
- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载。
- [AGC 总版本号与发号](./technical/【技术方案】AGC总版本号与发号-2026-09-20.md):客户端版本号收口到 OSS `agc/global-version.json`,统一构建一次发号供各渠道共用,渠道高水位降级为断言。
- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -0,0 +1,37 @@
# 里程碑:AGC 总版本号落地
状态:进行中(主规范已定稿,代码与 CI 已实现,等待首次真实统一构建验收)
## 关联文档
- 主规范:`docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md`
## 交付物
1. 发号模块与入口:`agc-global-version.mjs``issue-global-version.mjs``agc-global-version.test.mjs`
2. 构建侧改动:`build-release.mjs``prepareReleaseVersion()` 采用总号,高水位降级为断言。
3. CI:发号 Job`Jenkinsfile.agc-global-version-issue` + job config)、调度管线与手动管线接入发号 Job。
4. 文档与项目记忆更新。
## 实现顺序与门禁
| 步骤 | 内容 | 门禁 |
| --- | --- | --- |
| 1 | 发号模块 + 单元测试 | `node --test apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs` 全绿 |
| 2 | 构建侧接入总号与断言 | `build-release.test.mjs` / `release-oss.test.mjs` / `cargo-features.test.mjs` 全绿 |
| 3 | 发号 Job 与管线接入 | `npm run check:production-ops``npm run check:encoding` 通过 |
| 4 | 一次性播种 | 发号 Job 勾选 `SEED_ONLY` 写入基线,回读一致 |
| 5 | 首次统一构建 | dev-win 与 dev-mac 清单版本相同且等于总号 |
| 6 | 单渠道热修回归 | 只有该渠道清单变化,总号 +1 |
## 当前证据
- `agc-global-version.test.mjs`:7/7 通过(播种基线、首发、递增、dry-run 不烧号、回退断言、并发写后回读失败关闭、patch 递增)。
- AGC 相关脚本合跑:32/32 通过。
- 真实 OSS 只读预览:`--dry-run --channel unified` 输出 `AGC_GLOBAL_VERSION=0.1.77`(基线 0.1.76,未写回)。
- `npm run check:production-ops``npm run check:encoding` 通过。
## 未完成项
- 首次真实播种与统一构建尚未执行(避免在功能未合并前烧号)。
- `feat/jenkins-mac-build` 分支上的 macOS 管线仍用旧的自增高水位逻辑;该分支合并 master 后才继承本方案的模块与断言,合并前 dev-mac 必须由发号 Job 显式传入 `AGC_RELEASE_VERSION`
@@ -9006,3 +9006,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 新增护栏:`scripts/project-ci-workflow.test.ts` 增加一条一致性用例——Dockerfile 的 `ARG RUST_IMAGE` 版本段必须等于 `rust-toolchain.toml` 的 channel 主次版本、其 digest 必须同时出现在两份运维文档里、镜像 tag 日期戳必须与 Dockerfile 的 `org.opencontainers.image.version` 一致,避免本次这种「改了 Dockerfile 忘了文档」的漂移。
- 待办(不在本次仓库改动内):在 station 上执行 `build / verify / export / load-runner`,备份 runner config 后把 `genarrative-ci` 映射切到新 Image ID 并 `docker restart --timeout 660 gitea-runner`;内层只有 1.96 时 PR 的 job 必然失败。macOS 与 Windows AGC 构建机(`jenkins/Jenkinsfile.ai-game-creator-shell-build`preflight 只校验 rustc/cargo 是否存在)需确认已装 `1.98.1`,macOS 冷构建是本次问题的原始验证目标。`deploy/container/api-server.Dockerfile``FROM rust:1.93-bookworm` 是另一处未加 digest 的 Rust 版本 pin,本次未动。
- 验证:分支 `chore/rust-toolchain-1-98` / PR #432`npx vitest run scripts/project-ci-workflow.test.ts``npm run check:encoding``git diff --check` 通过。Windows`1.98.1``npm run agc:build -- --debug` 的前端构建、Rust 编译与 NSIS 安装包生成成功(见 PR 描述记录);macOS 与镜像重建后的 CI 结果仍待验证。
## 2026-09-20 AGC 客户端版本号收敛为单一发号源
- 客户端版本号唯一事实源改为 OSS `agc/global-version.json`;渠道清单只写本次拿到的号,仓库里 5 个版本文件只作构建输入参考。
- 发号顺序固定「先写总号 → 再构建 → 再发渠道清单」,失败不回滚只烧号;统一构建发一次号供 `dev-win` / `dev-mac` 共用,单渠道热修只作用于该渠道。
- 发号收口到 Jenkins Job `Genarrative-Agc-Global-Version-Issue``disableConcurrentBuilds()`;集群无 `lockable-resources`,以写后回读不一致即失败关闭兜底并发)。
- 原渠道高水位逻辑降级为断言:请求号低于本渠道清单版本即失败关闭;`AGC_RELEASE_DRY_RUN` 只预览不烧号。
@@ -0,0 +1,62 @@
# AGC 总版本号与发号
更新时间:`2026-09-20`
## 背景
AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win``dev-mac` 各自读自己的渠道清单,`prepareReleaseVersion()` 取本地版本与远端高水位的较大值再 `patch + 1`。两个渠道因此天然拿到不同的号,统一构建无法保证 Windows 与 macOS 是同一个版本,手工填号或并发发号还会出现重号与回退。
本方案把客户端版本号收敛成单一发号源,渠道只写自己本次拿到的号。
## 版本源
- 唯一事实源是 OSS 对象 `agc/global-version.json`,字段:`version``updatedAt``channel``commit``buildId`
- 渠道清单(`agc/<channel>/latest.json`)仍写各自本次的版本号,但不再承担发号职责。
- 仓库里由构建改写的 5 个版本文件(`apps/ai-game-creator-shell/package.json`、根 `package-lock.json``src-tauri/tauri.conf.json``src-tauri/Cargo.toml``src-tauri/Cargo.lock`)继续按现状由构建改写,**只作构建输入参考、不作为事实源**,不提交、不参与发号。
## 发号规则
- `next = 总号 + 1`;总号不存在时先一次性播种,见下节。
- 顺序固定为「先写总号 → 再构建 → 再发渠道清单」;任何一步失败都不回滚,只烧号。
- 统一构建:发一次号,通过 `AGC_RELEASE_VERSION` 同时传给 `dev-win``dev-mac`(以及后续渠道),各渠道共用同一个号。
- 单渠道热修:发一次号,只传给该渠道;其他渠道清单保持原值。
- 显式传入 `AGC_RELEASE_VERSION` 的构建不再自行发号,只做高水位断言。
## 并发控制
- 发号收口到专用 Jenkins Job `Genarrative-Agc-Global-Version-Issue``disableConcurrentBuilds()` + 写后回读校验)。集群未安装 `lockable-resources` 插件,因此以 Job 级串行 + 写后回读兜底:写完总号后立刻回读,若远端值与本次写下不一致即失败关闭(号已烧,不重试、不回滚),由人工确认后再发。
- 各渠道构建只接收号,不自己加;本地手工兜底路径(未传 `AGC_RELEASE_VERSION`)同样走「读总号 → +1 → 写回 → 回读校验」。
- 不允许裸读改写:任何路径都必须经过发号模块,写后回读不一致即失败关闭。
## 一次性播种
- 基线 `seed = max(仓库当前版本, agc/dev-win/latest.json, agc/dev-mac/latest.json, agc/latest.json 旧指针)`
- 播种只写基线本身,不递增、不烧号;首个发放号是基线 + 1。
- 播种入口:`node apps/ai-game-creator-shell/scripts/issue-global-version.mjs --seed-only`,或发号 Job 勾选 `SEED_ONLY`
## 实现位置
- 发号模块:`apps/ai-game-creator-shell/scripts/agc-global-version.mjs`(读总号 / 播种 / 发号 / 写后回读 / 渠道高水位断言 / dry-run 预览)。
- 发号入口:`apps/ai-game-creator-shell/scripts/issue-global-version.mjs`CI 与本地共用,输出固定为 `AGC_GLOBAL_VERSION=<version>`)。
- 构建侧:`build-release.mjs``prepareReleaseVersion()` 优先采用传入总号,未传入时现场发号;`resolveRemoteHighWaterVersion()` 降级为断言来源,只用于「请求号低于本渠道清单版本即失败关闭」。
- CI`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。
## 不改的东西
- `appUpdate.ts` 与官方 updater 的比较逻辑、渠道清单端点。
- `release-oss.mjs` 上传流程。
- `agc/latest.json` 只由 `dev-win` 写入。
- 主站下载检查的读取源,不接总号。
## 边界约定
- `dev-mac` 在 macOS 构建机本地发布时同样只接收号(发号 Job 或本地发号脚本),不允许手动填号。
- `AGC_RELEASE_DRY_RUN` 只读总号并预览 +1,不写回、不烧号。
## 验收标准
1. 统一构建后,`dev-win``dev-mac` 清单版本相同且等于总号。
2. 单渠道热修后,只有该渠道清单变化,总号 +1,其他渠道清单原值不动。
3. 热修后再统一构建,总号继续 +1,其他渠道版本跳过中间号。
4. 并发两次发号不出现重号。
5. 传入低于本渠道当前版本的号时构建失败关闭。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,146 @@
// AGC 总版本号发号 Job:唯一的发号入口。
//
// 设计要点(见 docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md):
// - 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」,失败不回滚,只烧号;
// - 各渠道构建只接收号(AGC_RELEASE_VERSION),不自己加号;
// - 本 Job 用 disableConcurrentBuilds() 串行化发号(集群未安装 lockable-resources
// 插件,因此以 Job 级串行 + 写后回读校验兜底并发);
// - dry-run 只预览下一位,不写回、不烧号。
pipeline {
agent {
label 'linux && genarrative-build'
}
options {
disableConcurrentBuilds()
skipDefaultCheckout(true)
buildDiscarder(logRotator(numToKeepStr: '100', artifactNumToKeepStr: '20'))
}
environment {
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
GLOBAL_VERSION_OUT = 'agc-global-version.txt'
OSSUTIL_TOOLS_DIR = '.jenkins-ossutil'
}
parameters {
choice(name: 'AGC_CHANNEL', choices: ['unified', 'dev-win', 'dev-mac'], description: '发号用途:unified 为统一构建(一个号同时给 dev-win 与 dev-mac),其余为单渠道热修')
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '取发号脚本的源码分支;默认 master')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选:固定提交;留空则解析 SOURCE_BRANCH 当前 revision')
booleanParam(name: 'SEED_ONLY', defaultValue: false, description: '仅做一次性播种:写入渠道与仓库版本的最大值作为基线,不递增、不烧号')
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只预览下一个总号,不写回、不烧号')
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径;缺省时本 Job 会下载到工作区私有目录')
}
stages {
stage('Checkout') {
steps {
deleteDir()
script {
def checkoutFromRemote = { String remoteUrl, String credentialsId ->
checkout([
$class: 'GitSCM',
branches: [[name: params.COMMIT_HASH?.trim() ?: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [
[$class: 'CleanBeforeCheckout'],
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
],
userRemoteConfigs: [[
url: remoteUrl,
credentialsId: credentialsId,
refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}",
]],
])
}
checkoutFromRemote(env.GIT_REMOTE_URL, env.GIT_REMOTE_CREDENTIAL_ID)
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
if (params.COMMIT_HASH?.trim() && env.SOURCE_COMMIT != params.COMMIT_HASH.trim()) {
// 浅取分支不再含目标提交时立即失败,避免拿到错版本发号。
def contains = sh(
script: "git merge-base --is-ancestor ${params.COMMIT_HASH.trim()} HEAD && echo yes || echo no",
returnStdout: true,
).trim()
if (contains != 'yes') {
error("COMMIT_HASH ${params.COMMIT_HASH.trim()} 不属于 ${params.SOURCE_BRANCH}")
}
sh "git checkout --detach ${params.COMMIT_HASH.trim()}"
env.SOURCE_COMMIT = params.COMMIT_HASH.trim()
}
}
}
}
stage('Ensure ossutil') {
steps {
script {
def requested = params.OSSUTIL_BIN?.trim() ?: 'ossutil'
def resolved = sh(
script: "command -v ${requested} 2>/dev/null || true",
returnStdout: true,
).trim()
if (resolved) {
env.EFFECTIVE_OSSUTIL_BIN = resolved
} else {
// 构建节点没有 ossutil 时下载到工作区私有目录,不改主机环境。
sh '''#!/usr/bin/env bash
set -euo pipefail
version="${AGC_OSSUTIL_VERSION:-2.1.2}"
mkdir -p "${OSSUTIL_TOOLS_DIR}"
if [[ ! -x "${OSSUTIL_TOOLS_DIR}/ossutil" ]]; then
curl -fsSL -o "${OSSUTIL_TOOLS_DIR}/ossutil.zip" \\
"https://gosspublic.alicdn.com/ossutil/v2/${version}/ossutil-${version}-linux-amd64.zip"
unzip -o -q "${OSSUTIL_TOOLS_DIR}/ossutil.zip" -d "${OSSUTIL_TOOLS_DIR}"
chmod +x "${OSSUTIL_TOOLS_DIR}/ossutil"
fi
"${OSSUTIL_TOOLS_DIR}/ossutil" --version | head -1
'''
env.EFFECTIVE_OSSUTIL_BIN = "${env.WORKSPACE}/${env.OSSUTIL_TOOLS_DIR}/ossutil"
}
}
}
}
stage('Issue Global Version') {
steps {
withCredentials([
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'),
]) {
withEnv([
"OSSUTIL_BIN=${env.EFFECTIVE_OSSUTIL_BIN}",
"AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
]) {
sh '''#!/usr/bin/env bash
set -euo pipefail
seed_args=()
if [[ "${SEED_ONLY}" == "true" ]]; then seed_args+=(--seed-only); fi
dry_args=()
if [[ "${AGC_RELEASE_DRY_RUN}" == "1" ]]; then dry_args+=(--dry-run); fi
node apps/ai-game-creator-shell/scripts/issue-global-version.mjs \\
--channel "${AGC_CHANNEL}" \\
--commit "${SOURCE_COMMIT}" \\
--build-id "${BUILD_NUMBER}" \\
--out "${GLOBAL_VERSION_OUT}" \\
"${seed_args[@]}" "${dry_args[@]}"
'''
script {
env.AGC_GLOBAL_VERSION = readFile(env.GLOBAL_VERSION_OUT).trim()
currentBuild.description =
"AGC 总版本号 ${env.AGC_GLOBAL_VERSION}channel=${params.AGC_CHANNEL}" +
"${params.SEED_ONLY ? ',播种' : ''}${params.AGC_RELEASE_DRY_RUN ? 'dry-run' : ''}"
echo currentBuild.description
}
}
}
}
}
}
post {
always {
archiveArtifacts artifacts: "agc-global-version.txt", allowEmptyArchive: true, fingerprint: true
}
}
}
@@ -21,7 +21,7 @@ pipeline {
parameters {
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch')
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '三段版本号;统一构建由 Genarrative-Agc-Global-Version-Issue 发号后透传;留空时本地兜底发号(读 OSS 总号 +1 并写回)')
string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev', description: 'AGC 发布渠道:dev、release 或自定义小写名称;此 Job 构建 WindowsmacOS 在对应构建机执行')
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要')
+24 -1
View File
@@ -20,6 +20,8 @@ pipeline {
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'
AGC_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'
AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'
AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'
REVISION_STATE_FILE = '.jenkins-last-triggered-revision'
AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache'
}
@@ -153,7 +155,28 @@ pipeline {
}
def agcTriggered = false
if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') {
build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
// 客户端版本号只在发号 Job 里产生:先发号,再让渠道构建接收号并发布。
def versionRun = build job: env.AGC_GLOBAL_VERSION_JOB_NAME,
wait: true,
propagate: true,
parameters: [
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
string(name: 'COMMIT_HASH', value: pinnedRevision),
string(name: 'AGC_CHANNEL', value: 'dev-win'),
]
copyArtifacts(
projectName: env.AGC_GLOBAL_VERSION_JOB_NAME,
selector: specific(versionRun.number.toString()),
filter: env.AGC_GLOBAL_VERSION_ARTIFACT,
target: '.',
flatten: true,
)
env.AGC_GLOBAL_VERSION = readFile(env.AGC_GLOBAL_VERSION_ARTIFACT).trim()
echo "本轮 AGC 客户端版本号: ${env.AGC_GLOBAL_VERSION}"
def agcParameters = pinnedParameters + [
string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION),
]
build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters
agcTriggered = true
} else {
echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
@@ -0,0 +1,83 @@
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="workflow-job">
<actions/>
<description>AGC 总版本号唯一发号入口:先写 OSS agc/global-version.json,再由各渠道构建接收号发布。统一构建发一次号给所有渠道;dry-run 只预览不烧号。</description>
<keepDependencies>false</keepDependencies>
<properties>
<hudson.model.ParametersDefinitionProperty>
<parameterDefinitions>
<hudson.model.ChoiceParameterDefinition>
<name>AGC_CHANNEL</name>
<description>发号用途:unified 为统一构建(一个号同时给 dev-win 与 dev-mac),其余为单渠道热修</description>
<choices class="java.util.Arrays$ArrayList">
<a class="string-array">
<string>unified</string>
<string>dev-win</string>
<string>dev-mac</string>
</a>
</choices>
</hudson.model.ChoiceParameterDefinition>
<hudson.model.StringParameterDefinition>
<name>SOURCE_BRANCH</name>
<description>取发号脚本的源码分支;默认 master</description>
<defaultValue>master</defaultValue>
<trim>true</trim>
</hudson.model.StringParameterDefinition>
<hudson.model.StringParameterDefinition>
<name>COMMIT_HASH</name>
<description>可选:固定提交;留空则解析 SOURCE_BRANCH 当前 revision</description>
<defaultValue></defaultValue>
<trim>true</trim>
</hudson.model.StringParameterDefinition>
<hudson.model.BooleanParameterDefinition>
<name>SEED_ONLY</name>
<description>仅做一次性播种:写入渠道与仓库版本的最大值作为基线,不递增、不烧号</description>
<defaultValue>false</defaultValue>
</hudson.model.BooleanParameterDefinition>
<hudson.model.BooleanParameterDefinition>
<name>AGC_RELEASE_DRY_RUN</name>
<description>勾选后只预览下一个总号,不写回、不烧号</description>
<defaultValue>false</defaultValue>
</hudson.model.BooleanParameterDefinition>
<hudson.model.StringParameterDefinition>
<name>OSSUTIL_BIN</name>
<description>ossutil 命令名或绝对路径;缺省时本 Job 会下载到工作区私有目录</description>
<defaultValue>ossutil</defaultValue>
<trim>true</trim>
</hudson.model.StringParameterDefinition>
</parameterDefinitions>
</hudson.model.ParametersDefinitionProperty>
</properties>
<definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="workflow-cps">
<scm class="hudson.plugins.git.GitSCM" plugin="git">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git</url>
<credentialsId>genarrative-local-gitea-ssh</credentialsId>
<refspec>+refs/heads/master:refs/remotes/origin/master</refspec>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/master</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="empty-list"/>
<extensions>
<hudson.plugins.git.extensions.impl.CloneOption>
<shallow>true</shallow>
<noTags>true</noTags>
<reference></reference>
<depth>1</depth>
<honorRefspec>true</honorRefspec>
</hudson.plugins.git.extensions.impl.CloneOption>
</extensions>
</scm>
<scriptPath>jenkins/Jenkinsfile.agc-global-version-issue</scriptPath>
<lightweight>false</lightweight>
</definition>
<triggers/>
<disabled>false</disabled>
</flow-definition>
+28 -2
View File
@@ -7697,12 +7697,38 @@ for (const [snippet, reason] of [
const scheduledPinnedParameterCalls = scheduledRevisionTriggerContent.match(
/parameters: pinnedParameters/gu,
);
if ((scheduledPinnedParameterCalls?.length ?? 0) !== 2) {
// AGC 客户端版本号统一由发号 Job 产生:调度管线只能在 pinnedParameters 之上
// 追加 AGC_RELEASE_VERSION,不能另起一份 revision 或自己递增渠道版本。
if (
(scheduledPinnedParameterCalls?.length ?? 0) !== 1 ||
!scheduledRevisionTriggerContent.includes(
'def agcParameters = pinnedParameters + [',
)
) {
failed = true;
console.error(
'[check:production-ops] 调度管线必须用同一份 pinnedParameters 同时触发 Full Build 与 AGC Windows Build。',
'[check:production-ops] 调度管线必须 Full Build 与 AGC Windows Build 共用同一份 pinnedParametersAGC 只允许追加发号 Job 下发的总版本号)。',
);
}
for (const [snippet, reason] of [
[
"AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'",
'必须把客户端版本号收口到专用发号 Job',
],
[
"string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION)",
'必须把发号 Job 下发的总版本号透传给 AGC Windows Build',
],
[
"AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'",
'必须从发号 Job 的归档产物读取总版本号',
],
]) {
if (!scheduledRevisionTriggerContent.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] 调度管线${reason}`);
}
}
if (
!scheduledRevisionTriggerJobConfig.includes(
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',