Merge branch 'master' into codex/agc-browser-orphan-cleanup
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled

This commit is contained in:
2026-09-22 19:04:55 +08:00
4 changed files with 71 additions and 33 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;
}
@@ -1077,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) => ({
@@ -1099,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), '');
});
@@ -1122,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', '.');
@@ -1149,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, [
'客户端:侧分支改动',
@@ -138,7 +138,7 @@
- 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 devWindows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。
- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName``identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。
- 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt``AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题`标题只取 commit message 第一行并忽略后续说明行;最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt``AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
- 上传:安装包与 `.sig` 上传到 `agc/<channel>-win|mac/<version>/`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
- 首装发布:发布脚本生成 `downloads`Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。
File diff suppressed because one or more lines are too long