Merge remote-tracking branch 'refs/remotes/origin/master' into refactor/extract-dep-from-ref-inputer
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m30s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Successful in 7m34s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m9s
Project CI / Repository checks (pull_request) Successful in 3m36s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-22 18:51:27 +08:00
14 changed files with 382 additions and 109 deletions
@@ -648,6 +648,32 @@ function readHeadCommit() {
}
}
/**
* Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行;
* 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。
*/
function commitMessageTitle(message) {
return String(message ?? '')
.split(/\r?\n/u, 1)[0]
.trim();
}
/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */
function parseReleaseCommitLog(output) {
return output
.split('\0')
.map((record) => record.trimEnd())
.filter(Boolean)
.map((record) => {
const separator = record.indexOf('\t');
if (separator < 0) return null;
const sha = record.slice(0, separator);
const subject = commitMessageTitle(record.slice(separator + 1));
return sha && subject ? { sha, subject } : null;
})
.filter(Boolean);
}
/**
* 上一次发布到本次之间的客户端相关提交。
*
@@ -675,8 +701,9 @@ export function collectReleaseCommits(
'git',
[
'log',
'-z',
'--no-merges',
'--format=%h%x09%s',
'--format=%h%x09%B',
`${previousCommit}..${headCommit}`,
'--',
...paths,
@@ -686,28 +713,22 @@ export function collectReleaseCommits(
} catch {
return null;
}
return output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha = '', ...subject] = line.split('\t');
return { sha, subject: subject.join('\t') };
});
return parseReleaseCommitLog(output);
}
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */
export function formatReleaseNotes(
commits,
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
) {
if (!commits || commits.length === 0) return '';
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
const lines = commits.slice(0, limit).map(({ subject }) => {
const title = commitMessageTitle(subject);
const trimmed =
subject.length > subjectLength
? `${subject.slice(0, subjectLength - 1)}…`
: subject;
return `- ${trimmed}(${sha})`;
title.length > subjectLength
? `${title.slice(0, subjectLength - 1)}…`
: title;
return `- ${trimmed}`;
});
if (commits.length > limit) {
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
@@ -726,20 +747,21 @@ export function collectRecentReleaseCommits({
try {
output = execFileSync(
'git',
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
[
'log',
'-z',
'--no-merges',
`-n${limit}`,
'--format=%h%x09%B',
'--',
...paths,
],
{ cwd, encoding: 'utf8' },
);
} catch {
return null;
}
const commits = output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sha = '', ...subject] = line.split('\t');
return { sha, subject: subject.join('\t') };
});
const commits = parseReleaseCommitLog(output);
return commits.length > 0 ? commits : null;
}
@@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme
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');
},
});
await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () =>
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']);
});
@@ -1075,20 +1077,17 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade
assert.ok(source.includes('\n dryRun,\n'));
});
test('release notes list client commits with short sha and bound their size', () => {
test('release notes list client commit subjects only 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(lines[0], '- 客户端更新切换到官方更新插件');
const truncatedSubject = lines[1].replace(/^- /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) => ({
@@ -1097,6 +1096,15 @@ test('release notes list client commits with short sha and bound their size', ()
})),
);
assert.match(many, /- 其余 8 项客户端改动省略$/u);
assert.equal(
formatReleaseNotes([
{
sha: 'ignored',
subject: '提交标题\n不应展示的说明一\n不应展示的说明二',
},
]),
'- 提交标题',
);
assert.equal(formatReleaseNotes([]), '');
assert.equal(formatReleaseNotes(null), '');
});
@@ -1120,8 +1128,17 @@ test('release commits cover only client paths and skip merge commits', () => {
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
'fn main() {}\n',
);
const commitMessagePath = path.join(
directory,
'.git',
'commit-message.txt',
);
writeFileSync(
commitMessagePath,
'客户端:新增更新插件接入\n补充更新插件接入的详细说明\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
git('commit', '--quiet', '-F', commitMessagePath);
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
git('add', '.');
@@ -1147,6 +1164,7 @@ test('release commits cover only client paths and skip merge commits', () => {
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
assert.ok(commits, '应能在临时仓库里收集提交');
const subjects = commits.map((entry) => entry.subject);
// 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
assert.deepEqual(subjects, [
'客户端:侧分支改动',