724d94c95c
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m22s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m20s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 6m42s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 6m1s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m4s
Project CI / Native shell tests (push) Failing after 1m29s
Project CI / AI game creator shell Rust crates (push) Successful in 2m15s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
- 摘要锚点读取失败时降级为无锚点并告警,不再让整个发布失败 - 无锚点时列出最近 8 条客户端相关提交,并注明可能与上一版重复 - 新增锚点读取失败降级与最近提交兜底的定向用例 - 技术方案补充摘要不阻断发布的约束
487 lines
16 KiB
JavaScript
487 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import {
|
||
mkdirSync,
|
||
mkdtempSync,
|
||
readFileSync,
|
||
rmSync,
|
||
writeFileSync,
|
||
} from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { test } from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
agcReleasePathPatterns,
|
||
collectRecentReleaseCommits,
|
||
collectReleaseCommits,
|
||
compareVersions,
|
||
createChannelConfig,
|
||
createLegacyUpdateManifest,
|
||
createUpdateManifest,
|
||
formatRecentReleaseNotes,
|
||
formatReleaseNotes,
|
||
nextPatchVersion,
|
||
resolveManifestPlatformKeys,
|
||
resolvePreviousReleaseCommit,
|
||
resolveReleaseChannel,
|
||
resolveRemoteHighWaterVersion,
|
||
selectReleaseArtifact,
|
||
updateManifestUrl,
|
||
} from './build-release.mjs';
|
||
|
||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||
const universalTarget = 'universal-apple-darwin';
|
||
|
||
function withEnv(overrides, run) {
|
||
const previous = new Map();
|
||
for (const [key, value] of Object.entries(overrides)) {
|
||
previous.set(key, process.env[key]);
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
}
|
||
try {
|
||
return run();
|
||
} finally {
|
||
for (const [key, value] of previous) {
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
function withSignedArtifact(fileName, run) {
|
||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||
try {
|
||
const artifact = path.join(directory, fileName);
|
||
writeFileSync(artifact, 'installation package');
|
||
writeFileSync(`${artifact}.sig`, 'signature-content\n');
|
||
return run(artifact);
|
||
} finally {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function jsonResponse(body, status = 200) {
|
||
return {
|
||
status,
|
||
ok: status >= 200 && status < 300,
|
||
json: async () => body,
|
||
};
|
||
}
|
||
|
||
function withStubbedFetch(handler, run) {
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = async (url) => handler(String(url));
|
||
return Promise.resolve(run()).finally(() => {
|
||
globalThis.fetch = originalFetch;
|
||
});
|
||
}
|
||
|
||
test('selects an explicit release artifact when configured', () => {
|
||
const artifactPath = fileURLToPath(
|
||
new URL('../package.json', import.meta.url),
|
||
);
|
||
withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
|
||
assert.equal(selectReleaseArtifact([]), artifactPath);
|
||
});
|
||
});
|
||
|
||
test('does not select unsupported files', () => {
|
||
assert.equal(
|
||
selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']),
|
||
null,
|
||
);
|
||
});
|
||
|
||
test('resolves the channel from the target platform and rejects mismatches', () => {
|
||
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
|
||
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
|
||
assert.equal(
|
||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
|
||
'dev-mac',
|
||
);
|
||
assert.throws(
|
||
() =>
|
||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
|
||
/只能用于 darwin 目标/u,
|
||
);
|
||
assert.throws(
|
||
() =>
|
||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
|
||
/未知发布渠道/u,
|
||
);
|
||
});
|
||
|
||
test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||
assert.equal(
|
||
updateManifestUrl('dev-win'),
|
||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||
);
|
||
assert.deepEqual(createChannelConfig('dev-mac'), {
|
||
plugins: {
|
||
updater: {
|
||
endpoints: [
|
||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
|
||
],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
});
|
||
|
||
test('universal macOS builds publish one artifact under both platform keys', () => {
|
||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||
'darwin-aarch64',
|
||
'darwin-x86_64',
|
||
]);
|
||
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
|
||
'windows-x86_64',
|
||
]);
|
||
});
|
||
|
||
test('channel manifest carries version, platform keys and signature', () => {
|
||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||
const manifest = createUpdateManifest(artifact, {
|
||
channel: 'dev-win',
|
||
target: windowsTarget,
|
||
publishedAt: '2026-09-17T00:00:00.000Z',
|
||
});
|
||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||
assert.equal(manifest.notes, '修复与改进');
|
||
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
|
||
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
|
||
assert.equal(
|
||
manifest.platforms['windows-x86_64'].signature,
|
||
'signature-content',
|
||
);
|
||
assert.match(
|
||
manifest.platforms['windows-x86_64'].url,
|
||
new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
test('missing signature fails the channel manifest closed', () => {
|
||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||
try {
|
||
const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
|
||
writeFileSync(artifact, 'installation package');
|
||
assert.throws(
|
||
() =>
|
||
createUpdateManifest(artifact, {
|
||
channel: 'dev-win',
|
||
target: windowsTarget,
|
||
}),
|
||
/缺少更新包签名/u,
|
||
);
|
||
} finally {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('legacy manifest keeps the sha256 contract of published clients', () => {
|
||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||
const legacy = createLegacyUpdateManifest(artifact, {
|
||
channel: 'dev-win',
|
||
});
|
||
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
|
||
assert.equal(legacy.sha256.length, 64);
|
||
assert.equal(legacy.size, 'installation package'.length);
|
||
assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
|
||
});
|
||
});
|
||
|
||
test('next release version follows the higher local or channel version', () => {
|
||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||
});
|
||
|
||
test('version high water keeps the legacy pointer during the migration window', async () => {
|
||
await withStubbedFetch(
|
||
(url) =>
|
||
url.endsWith('/agc/dev-win/latest.json')
|
||
? jsonResponse({}, 404)
|
||
: jsonResponse({ version: '0.1.57' }),
|
||
async () => {
|
||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
|
||
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
|
||
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('version high water takes the higher of channel and legacy pointer', async () => {
|
||
await withStubbedFetch(
|
||
(url) =>
|
||
url.endsWith('/agc/dev-win/latest.json')
|
||
? jsonResponse({ version: '0.1.60' })
|
||
: jsonResponse({ version: '0.1.57' }),
|
||
async () => {
|
||
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('version high water ignores the windows migration pointer for other channels', async () => {
|
||
await withStubbedFetch(
|
||
(url) => {
|
||
assert.ok(
|
||
!url.endsWith('/agc/latest.json'),
|
||
'non-windows channel must not read the windows migration pointer',
|
||
);
|
||
return jsonResponse({ version: '0.1.12' });
|
||
},
|
||
async () => {
|
||
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
|
||
},
|
||
);
|
||
});
|
||
|
||
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
|
||
await withStubbedFetch(
|
||
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
|
||
async () => {
|
||
assert.equal(
|
||
await resolvePreviousReleaseCommit('dev-win', {
|
||
override: '6017d46088c04199e99cf89f347b12d67591475e',
|
||
}),
|
||
'6017d46088c04199e99cf89f347b12d67591475e',
|
||
);
|
||
// 覆盖值非法时忽略,继续用清单里的 commit。
|
||
assert.equal(
|
||
await resolvePreviousReleaseCommit('dev-win', {
|
||
override: 'not-a-sha',
|
||
}),
|
||
'abcdef1234567890',
|
||
);
|
||
assert.equal(
|
||
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
|
||
'abcdef1234567890',
|
||
);
|
||
},
|
||
);
|
||
|
||
await withStubbedFetch(
|
||
() => jsonResponse({ version: '0.1.61' }),
|
||
async () => {
|
||
assert.equal(
|
||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||
null,
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = async () => {
|
||
throw new Error('fetch failed');
|
||
};
|
||
try {
|
||
assert.equal(
|
||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||
null,
|
||
);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
test('recent commit fallback marks that entries may repeat the previous release', () => {
|
||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
|
||
const git = (...args) =>
|
||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||
try {
|
||
git('init', '--quiet');
|
||
git('config', 'user.email', 'release@example.test');
|
||
git('config', 'user.name', 'release test');
|
||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||
recursive: true,
|
||
});
|
||
for (const name of ['one', 'two']) {
|
||
writeFileSync(
|
||
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
|
||
`fn ${name}() {}\n`,
|
||
);
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', `客户端:${name}`);
|
||
}
|
||
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', '文档:说明');
|
||
|
||
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
|
||
assert.deepEqual(
|
||
recent.map((entry) => entry.subject),
|
||
['客户端:two', '客户端:one'],
|
||
);
|
||
const notes = formatRecentReleaseNotes(recent);
|
||
assert.match(
|
||
notes,
|
||
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
|
||
);
|
||
assert.equal(formatRecentReleaseNotes([]), '');
|
||
assert.equal(formatRecentReleaseNotes(null), '');
|
||
} finally {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
||
const source = readFileSync(
|
||
new URL('./release-upload.mjs', import.meta.url),
|
||
'utf8',
|
||
);
|
||
assert.equal(
|
||
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
|
||
4,
|
||
);
|
||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||
assert.match(source, /agc\/latest\.json/u);
|
||
});
|
||
|
||
test('release notes list client commits with short sha and bound their size', () => {
|
||
const notes = formatReleaseNotes([
|
||
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
|
||
{ sha: '55af6014', subject: '修'.repeat(120) },
|
||
]);
|
||
const lines = notes.split('\n');
|
||
assert.equal(lines.length, 2);
|
||
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
|
||
const truncatedSubject = lines[1]
|
||
.replace(/^- /u, '')
|
||
.replace(/(55af6014)$/u, '');
|
||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||
assert.match(truncatedSubject, /…$/u);
|
||
assert.match(lines[1], /(55af6014)$/u);
|
||
|
||
const many = formatReleaseNotes(
|
||
Array.from({ length: 20 }, (_, index) => ({
|
||
sha: `sha${index}`,
|
||
subject: `改动 ${index}`,
|
||
})),
|
||
);
|
||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||
assert.equal(formatReleaseNotes([]), '');
|
||
assert.equal(formatReleaseNotes(null), '');
|
||
});
|
||
|
||
test('release commits cover only client paths and skip merge commits', () => {
|
||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
|
||
const git = (...args) =>
|
||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||
try {
|
||
git('init', '--quiet');
|
||
git('config', 'user.email', 'release@example.test');
|
||
git('config', 'user.name', 'release test');
|
||
git('commit', '--allow-empty', '--quiet', '-m', '基点');
|
||
const base = git('rev-parse', 'HEAD').trim();
|
||
|
||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||
recursive: true,
|
||
});
|
||
mkdirSync(path.join(directory, 'docs'), { recursive: true });
|
||
writeFileSync(
|
||
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
|
||
'fn main() {}\n',
|
||
);
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
|
||
|
||
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', '文档:补充说明');
|
||
|
||
writeFileSync(
|
||
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
|
||
'fn other() {}\n',
|
||
);
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', '客户端:修复版本回退');
|
||
|
||
git('checkout', '--quiet', '-b', 'side');
|
||
writeFileSync(
|
||
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
|
||
'fn side() {}\n',
|
||
);
|
||
git('add', '.');
|
||
git('commit', '--quiet', '-m', '客户端:侧分支改动');
|
||
git('checkout', '--quiet', 'master');
|
||
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
|
||
|
||
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
|
||
assert.ok(commits, '应能在临时仓库里收集提交');
|
||
const subjects = commits.map((entry) => entry.subject);
|
||
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
|
||
assert.deepEqual(subjects, [
|
||
'客户端:侧分支改动',
|
||
'客户端:修复版本回退',
|
||
'客户端:新增更新插件接入',
|
||
]);
|
||
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
|
||
|
||
assert.equal(
|
||
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
|
||
null,
|
||
);
|
||
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
|
||
} finally {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('scheduler path filter stays in sync with client release paths', () => {
|
||
const jenkinsfile = readFileSync(
|
||
new URL(
|
||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||
import.meta.url,
|
||
),
|
||
'utf8',
|
||
);
|
||
const filterLine = jenkinsfile
|
||
.split('\n')
|
||
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
|
||
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
|
||
for (const pattern of agcReleasePathPatterns) {
|
||
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
|
||
assert.ok(
|
||
filterLine.includes(bashPattern),
|
||
`调度管线过滤缺少 ${bashPattern}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('scheduler skips the full build only for non-deploy paths', () => {
|
||
const jenkinsfile = readFileSync(
|
||
new URL(
|
||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||
import.meta.url,
|
||
),
|
||
'utf8',
|
||
);
|
||
const skipLine = jenkinsfile
|
||
.split('\n')
|
||
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
|
||
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
|
||
for (const pattern of [
|
||
'docs/*',
|
||
'.codex/*',
|
||
'jenkins/*',
|
||
'apps/ai-game-creator-shell/*',
|
||
'apps/mobile-shell/*',
|
||
'apps/desktop-shell/*',
|
||
'apps/preview-deployer-web/*',
|
||
'tools/*',
|
||
'*.md',
|
||
]) {
|
||
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
|
||
}
|
||
});
|