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
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:
@@ -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);
|
||||
}
|
||||
@@ -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 构建 Windows,macOS 在对应构建机执行')
|
||||
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
|
||||
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要')
|
||||
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',
|
||||
|
||||
Reference in New Issue
Block a user