3fc3399032
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m41s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m11s
Project CI / Repository checks (pull_request) Successful in 4m22s
Project CI / Frontend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 9m52s
Project CI / Backend tests (pull_request) Successful in 11m3s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m41s
新增内置 Unity 插件与自包含 Attach helper,固定上游版本并保留许可证 统一 Runner 执行归属、项目身份校验、回执确认和不确定结果阻断 接入 Unity 工程导入、Agent 工具目录及 Windows 构建分发 补齐插件测试、CI 执行入口和 Windows .NET 10 工具链检查 记录真实 Unity 连接、执行、重载恢复及 Runner 边界验证
727 lines
24 KiB
JavaScript
727 lines
24 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,
|
||
buildRelease,
|
||
buildTauriBuildArguments,
|
||
collectRecentReleaseCommits,
|
||
collectReleaseCommits,
|
||
compareVersions,
|
||
createChannelConfig,
|
||
createLegacyUpdateManifest,
|
||
createUpdateManifest,
|
||
formatRecentReleaseNotes,
|
||
formatReleaseNotes,
|
||
generateUpdateManifest,
|
||
nextPatchVersion,
|
||
resolveManifestPlatformKeys,
|
||
resolvePreviousReleaseCommit,
|
||
resolveReleaseChannel,
|
||
resolveReleaseContext,
|
||
resolveRemoteHighWaterVersion,
|
||
runTauriBuild,
|
||
selectReleaseArtifact,
|
||
updateManifestUrl,
|
||
} from './build-release.mjs';
|
||
|
||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||
const universalTarget = 'universal-apple-darwin';
|
||
|
||
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
||
assert.throws(
|
||
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
|
||
/单架构/,
|
||
);
|
||
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
||
assert.deepEqual(buildTauriBuildArguments([], target), [
|
||
'build',
|
||
'--target',
|
||
target,
|
||
]);
|
||
}
|
||
});
|
||
|
||
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('macOS manifests only advertise the architecture actually built', () => {
|
||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
||
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
||
'darwin-aarch64',
|
||
]);
|
||
assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [
|
||
'darwin-x86_64',
|
||
]);
|
||
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
|
||
'windows-x86_64',
|
||
]);
|
||
});
|
||
|
||
test('release context resolves explicit targets before environment/default and fails closed', () => {
|
||
for (const args of [
|
||
['--target', 'aarch64-apple-darwin'],
|
||
['--target=aarch64-apple-darwin'],
|
||
['-t', 'aarch64-apple-darwin'],
|
||
]) {
|
||
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
|
||
const context = resolveReleaseContext(args, env);
|
||
assert.equal(context.target, 'aarch64-apple-darwin');
|
||
assert.equal(context.channel, 'dev-mac');
|
||
assert.match(
|
||
context.bundleRoot.replaceAll('\\', '/'),
|
||
/target\/aarch64-apple-darwin\/release\/bundle$/,
|
||
);
|
||
assert.ok(Object.isFrozen(context));
|
||
}
|
||
assert.throws(
|
||
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
|
||
/只能用于 windows/,
|
||
);
|
||
}
|
||
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
|
||
assert.equal(
|
||
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
|
||
.channel,
|
||
'dev-mac',
|
||
);
|
||
for (const args of [
|
||
['--target'],
|
||
['--target='],
|
||
['--target', '--no-bundle'],
|
||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||
['--target', universalTarget],
|
||
['--target', 'unknown'],
|
||
])
|
||
assert.throws(() => resolveReleaseContext(args, {}));
|
||
});
|
||
|
||
test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => {
|
||
const calls = [];
|
||
const seenContexts = [];
|
||
await withStubbedFetch(
|
||
(url) => {
|
||
calls.push(url);
|
||
assert.match(url, /\/dev-mac\/latest\.json$/);
|
||
return jsonResponse({ version: '0.1.67' });
|
||
},
|
||
() =>
|
||
withEnv(
|
||
{ AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined },
|
||
() =>
|
||
buildRelease(['--target', 'aarch64-apple-darwin'], {
|
||
prepareVersion: async (context) => {
|
||
seenContexts.push(context);
|
||
assert.equal(
|
||
await resolveRemoteHighWaterVersion(context.channel),
|
||
'0.1.67',
|
||
);
|
||
},
|
||
build: (args, context) => {
|
||
seenContexts.push(context);
|
||
runTauriBuild(args, context, {
|
||
spawn: (_binary, command) => {
|
||
const configIndex = command.lastIndexOf('--config');
|
||
const config = JSON.parse(
|
||
readFileSync(command[configIndex + 1], 'utf8'),
|
||
);
|
||
assert.match(
|
||
config.plugins.updater.endpoints[0],
|
||
/\/dev-mac\/latest\.json$/,
|
||
);
|
||
assert.ok(command.includes('aarch64-apple-darwin'));
|
||
assert.ok(
|
||
!command.includes('--features=cocos-editor-execute'),
|
||
);
|
||
return { status: 0 };
|
||
},
|
||
});
|
||
},
|
||
generateManifest: (context) => {
|
||
seenContexts.push(context);
|
||
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
|
||
assert.equal(
|
||
selectReleaseArtifact(
|
||
['/tmp/win.exe', artifact, '/tmp/mac.dmg'],
|
||
context.target,
|
||
),
|
||
artifact,
|
||
);
|
||
const manifest = createUpdateManifest(artifact, context);
|
||
assert.deepEqual(Object.keys(manifest.platforms), [
|
||
'darwin-aarch64',
|
||
]);
|
||
assert.match(
|
||
manifest.platforms['darwin-aarch64'].url,
|
||
/\/dev-mac\//,
|
||
);
|
||
});
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针');
|
||
assert.equal(seenContexts.length, 3);
|
||
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
|
||
});
|
||
|
||
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
|
||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
|
||
try {
|
||
const artifact = path.join(root, '陶泥儿.app.tar.gz');
|
||
writeFileSync(artifact, 'mac package');
|
||
writeFileSync(`${artifact}.sig`, 'mac signature');
|
||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||
const context = {
|
||
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
|
||
bundleRoot: root,
|
||
};
|
||
const result = await withStubbedFetch(
|
||
(url) => {
|
||
assert.match(url, /\/dev-mac\/latest\.json$/);
|
||
return jsonResponse({}, 404);
|
||
},
|
||
() => generateUpdateManifest(context),
|
||
);
|
||
assert.equal(result.artifact, artifact);
|
||
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
|
||
assert.equal(result.legacyManifestPath, null);
|
||
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
|
||
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
|
||
} finally {
|
||
rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('invalid target or mismatched channel fails before any release side effect', async () => {
|
||
let touched = false;
|
||
const sideEffects = {
|
||
prepareVersion: () => {
|
||
touched = true;
|
||
},
|
||
build: () => {
|
||
touched = true;
|
||
},
|
||
generateManifest: () => {
|
||
touched = true;
|
||
},
|
||
};
|
||
await assert.rejects(
|
||
() => buildRelease(['--target', universalTarget], sideEffects),
|
||
/单架构/,
|
||
);
|
||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||
assert.rejects(
|
||
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
|
||
/只能用于 windows/,
|
||
),
|
||
);
|
||
assert.equal(touched, false);
|
||
});
|
||
|
||
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
|
||
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
||
for (const context of [
|
||
resolveReleaseContext([], {}),
|
||
resolveReleaseContext(['--target', windowsTarget], {
|
||
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
|
||
}),
|
||
]) {
|
||
assert.equal(context.channel, 'dev-win');
|
||
assert.equal(
|
||
selectReleaseArtifact(files, context.target),
|
||
'/tmp/windows.exe',
|
||
);
|
||
runTauriBuild(
|
||
['--target', windowsTarget, '--config', 'user-config.json'],
|
||
context,
|
||
{
|
||
spawn: (_binary, command) => {
|
||
assert.ok(
|
||
command.includes(
|
||
'--features=cocos-editor-execute,unity-editor-execute',
|
||
),
|
||
);
|
||
assert.ok(command.includes('user-config.json'));
|
||
const configIndex = command.lastIndexOf('--config');
|
||
const config = JSON.parse(
|
||
readFileSync(command[configIndex + 1], 'utf8'),
|
||
);
|
||
assert.match(
|
||
config.plugins.updater.endpoints[0],
|
||
/\/dev-win\/latest\.json$/,
|
||
);
|
||
return { status: 0 };
|
||
},
|
||
},
|
||
);
|
||
}
|
||
});
|
||
|
||
test('no-bundle smoke skips version writes and manifest generation', async () => {
|
||
const steps = [];
|
||
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||
prepareVersion: () => {
|
||
steps.push('version');
|
||
},
|
||
build: (_args, context) => {
|
||
steps.push(context.channel);
|
||
},
|
||
generateManifest: () => {
|
||
steps.push('manifest');
|
||
},
|
||
});
|
||
assert.deepEqual(steps, ['dev-mac']);
|
||
});
|
||
|
||
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);
|
||
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/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}`);
|
||
}
|
||
});
|