From 9e0cbc7041b4ec1500a1c7f0e7f4cc2f85c8b9b5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 20 Sep 2026 18:12:49 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E5=8F=91=E5=8F=B7=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 OSS agc/global-version.json 发号模块与发号入口脚本\n构建侧优先采用发号 Job 下发的总号,未传入时本地兜底发号\n渠道高水位降级为回退断言,请求号低于渠道清单版本即失败关闭\n新增 Genarrative-Agc-Global-Version-Issue 发号 Job 与 job config\n调度管线与手动管线先发号再透传 AGC_RELEASE_VERSION\n补充单元测试、生产运维门禁、主规范与里程碑文档 --- .../scripts/agc-global-version.mjs | 298 ++++++++++++++++++ .../scripts/agc-global-version.test.mjs | 183 +++++++++++ .../scripts/build-release.mjs | 33 +- .../scripts/issue-global-version.mjs | 121 +++++++ docs/README.md | 1 + .../【里程碑】AGC总版本号落地-2026-09-20.md | 37 +++ .../shared-memory/decision-log.md | 7 + ...技术方案】AGC总版本号与发号-2026-09-20.md | 62 ++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- jenkins/Jenkinsfile.agc-global-version-issue | 146 +++++++++ .../Jenkinsfile.ai-game-creator-shell-build | 2 +- .../Jenkinsfile.scheduled-revision-trigger | 25 +- .../agc-global-version-issue-job-config.xml | 83 +++++ scripts/check-production-ops-guardrails.mjs | 30 +- 14 files changed, 1021 insertions(+), 9 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/agc-global-version.mjs create mode 100644 apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs create mode 100644 apps/ai-game-creator-shell/scripts/issue-global-version.mjs create mode 100644 docs/project-memory/plans/【里程碑】AGC总版本号落地-2026-09-20.md create mode 100644 docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md create mode 100644 jenkins/Jenkinsfile.agc-global-version-issue create mode 100644 jenkins/agc-global-version-issue-job-config.xml diff --git a/apps/ai-game-creator-shell/scripts/agc-global-version.mjs b/apps/ai-game-creator-shell/scripts/agc-global-version.mjs new file mode 100644 index 000000000..e83ce2bd1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/agc-global-version.mjs @@ -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); +} diff --git a/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs new file mode 100644 index 000000000..17f2987c1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/agc-global-version.test.mjs @@ -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); +}); diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 48c0fbecc..7c20bbbda 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -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; } diff --git a/apps/ai-game-creator-shell/scripts/issue-global-version.mjs b/apps/ai-game-creator-shell/scripts/issue-global-version.mjs new file mode 100644 index 000000000..872759661 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/issue-global-version.mjs @@ -0,0 +1,121 @@ +/** + * AGC 总版本号发号入口(CI 发号 Job 与本地手工兜底共用)。 + * + * 用法: + * node scripts/issue-global-version.mjs --channel dev-win [--commit ] [--build-id ] [--out ] + * node scripts/issue-global-version.mjs --seed-only + * node scripts/issue-global-version.mjs --dry-run --channel dev-win # 只预览,不写回、不烧号 + * + * 输出固定为一行 `AGC_GLOBAL_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); +} diff --git a/docs/README.md b/docs/README.md index f6ae4bf15..77a0b8b61 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 diff --git a/docs/project-memory/plans/【里程碑】AGC总版本号落地-2026-09-20.md b/docs/project-memory/plans/【里程碑】AGC总版本号落地-2026-09-20.md new file mode 100644 index 000000000..a8913f5ff --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC总版本号落地-2026-09-20.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f4c843be1..5fa4d262f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 只预览不烧号。 diff --git a/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md new file mode 100644 index 000000000..00afd641a --- /dev/null +++ b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md @@ -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//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=`)。 +- 构建侧:`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. 传入低于本渠道当前版本的号时构建失败关闭。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ba0febb2a..34ce87c6d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -143,7 +143,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 `Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 +revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 diff --git a/jenkins/Jenkinsfile.agc-global-version-issue b/jenkins/Jenkinsfile.agc-global-version-issue new file mode 100644 index 000000000..7cda374df --- /dev/null +++ b/jenkins/Jenkinsfile.agc-global-version-issue @@ -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 + } + } +} diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 0e7fff89c..e36eb3611 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -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 构建 Windows,macOS 在对应构建机执行') booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS') text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要') diff --git a/jenkins/Jenkinsfile.scheduled-revision-trigger b/jenkins/Jenkinsfile.scheduled-revision-trigger index c2a8c0af5..d3213b016 100644 --- a/jenkins/Jenkinsfile.scheduled-revision-trigger +++ b/jenkins/Jenkinsfile.scheduled-revision-trigger @@ -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" diff --git a/jenkins/agc-global-version-issue-job-config.xml b/jenkins/agc-global-version-issue-job-config.xml new file mode 100644 index 000000000..858f39d5e --- /dev/null +++ b/jenkins/agc-global-version-issue-job-config.xml @@ -0,0 +1,83 @@ + + + + AGC 总版本号唯一发号入口:先写 OSS agc/global-version.json,再由各渠道构建接收号发布。统一构建发一次号给所有渠道;dry-run 只预览不烧号。 + false + + + + + AGC_CHANNEL + 发号用途:unified 为统一构建(一个号同时给 dev-win 与 dev-mac),其余为单渠道热修 + + + unified + dev-win + dev-mac + + + + + SOURCE_BRANCH + 取发号脚本的源码分支;默认 master + master + true + + + COMMIT_HASH + 可选:固定提交;留空则解析 SOURCE_BRANCH 当前 revision + + true + + + SEED_ONLY + 仅做一次性播种:写入渠道与仓库版本的最大值作为基线,不递增、不烧号 + false + + + AGC_RELEASE_DRY_RUN + 勾选后只预览下一个总号,不写回、不烧号 + false + + + OSSUTIL_BIN + ossutil 命令名或绝对路径;缺省时本 Job 会下载到工作区私有目录 + ossutil + true + + + + + + + 2 + + + ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git + genarrative-local-gitea-ssh + +refs/heads/master:refs/remotes/origin/master + + + + + */master + + + false + + + + true + true + + 1 + true + + + + jenkins/Jenkinsfile.agc-global-version-issue + false + + + false + diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 03d09e363..998b8dec2 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -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 共用同一份 pinnedParameters(AGC 只允许追加发号 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( 'jenkins/Jenkinsfile.scheduled-revision-trigger',