调整 AGC 更新弹窗并停止自动生成更新日志
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / Backend tests (push) Successful in 3m36s
Project CI / Frontend tests (push) Successful in 1m43s
Project CI / Native shell tests (push) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m35s
Project CI / Repository checks (push) Successful in 1m50s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m31s
Project CI / AI game creator shell web tests (push) Successful in 1m16s

- 更新弹窗改为窗口居中纵向排版

- 停止自动生成渠道清单更新摘要

- 仅保留 AGC_UPDATE_RELEASE_NOTES 手动更新说明

- 清理 Jenkins 摘要锚点与 macOS 额外拉取 master 逻辑

- 同步开发运维、技术方案和回归测试
This commit is contained in:
2026-09-23 16:18:04 +08:00
parent 016356e509
commit f426944701
10 changed files with 49 additions and 437 deletions
@@ -21,7 +21,7 @@ import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。
const repoRoot = path.resolve(appRoot, '..', '..');
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
function defaultTarget() {
@@ -96,8 +96,8 @@ const defaultOssBaseUrl =
export { resolveReleaseChannel } from './channel-identity.mjs';
/**
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定必须与这里保持一致,
* `build-release.test.mjs` 有守卫用例逐条比对两边。
*/
export const agcReleasePathPatterns = [
'apps/ai-game-creator-shell/',
@@ -228,43 +228,6 @@ async function readManifestVersion(manifestUrl, label) {
: parseVersion(manifest?.version, `${label} version`);
}
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
async function readRemoteChannelManifest(channel, target) {
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
}
/**
* 摘要锚点:上次发布对应的提交。
*
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
*/
export async function resolvePreviousReleaseCommit(
channel = resolveReleaseChannel(),
{
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
target = defaultTarget(),
} = {},
) {
const explicit = override?.trim();
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
return explicit;
}
try {
const manifest = await readRemoteChannelManifest(channel, target);
const commit =
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
} catch (error) {
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
console.warn(
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
);
return null;
}
}
/**
* 版本高水位:渠道清单与旧协议迁移指针取较大值。
*
@@ -632,7 +595,7 @@ export function createUpdateManifest(
pub_date: publishedAt,
platforms,
downloads,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点
// 非标准字段:更新插件会忽略,仅保留源码 revision 供线上排障
...(commit ? { commit } : {}),
};
}
@@ -648,129 +611,6 @@ 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);
}
/**
* 上一次发布到本次之间的客户端相关提交。
*
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
*/
export function collectReleaseCommits(
previousCommit,
headCommit = 'HEAD',
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
) {
if (!previousCommit) return null;
try {
for (const revision of [previousCommit, headCommit]) {
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
cwd,
stdio: 'pipe',
});
}
} catch {
return null;
}
let output;
try {
output = execFileSync(
'git',
[
'log',
'-z',
'--no-merges',
'--format=%h%x09%B',
`${previousCommit}..${headCommit}`,
'--',
...paths,
],
{ cwd, encoding: 'utf8' },
);
} catch {
return null;
}
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(({ subject }) => {
const title = commitMessageTitle(subject);
const trimmed =
title.length > subjectLength
? `${title.slice(0, subjectLength - 1)}`
: title;
return `- ${trimmed}`;
});
if (commits.length > limit) {
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
}
const text = lines.join('\n');
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}` : text;
}
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
export function collectRecentReleaseCommits({
cwd = repoRoot,
paths = agcReleasePathPatterns,
limit = 8,
} = {}) {
let output;
try {
output = execFileSync(
'git',
[
'log',
'-z',
'--no-merges',
`-n${limit}`,
'--format=%h%x09%B',
'--',
...paths,
],
{ cwd, encoding: 'utf8' },
);
} catch {
return null;
}
const commits = parseReleaseCommitLog(output);
return commits.length > 0 ? commits : null;
}
export function formatRecentReleaseNotes(commits) {
const notes = formatReleaseNotes(commits, { limit: 8 });
if (!notes) return '';
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
}
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
export function createLegacyUpdateManifest(
artifactPath,
@@ -810,19 +650,10 @@ export async function generateUpdateManifest(
version: readPackageJson().version,
artifact,
});
const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel, {
target,
});
const commits = collectReleaseCommits(previousCommit);
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
const notes =
manualNotes ||
formatReleaseNotes(commits) ||
formatRecentReleaseNotes(recentCommits);
if (!manualNotes && !notes) {
const notes = readReleaseNotes();
if (!notes) {
console.log(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
'[ai-game-creator-shell] 未生成更新摘要;如需携带说明,请设置 AGC_UPDATE_RELEASE_NOTES',
);
}
const manifest = createUpdateManifest(artifact, {
@@ -857,11 +688,9 @@ export async function generateUpdateManifest(
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
console.log(
manualNotes
notes
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
: notes && !previousCommit
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'}`,
: '[ai-game-creator-shell] 更新摘要:未生成',
);
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
if (legacyManifestPath) {
@@ -878,8 +707,6 @@ export async function generateUpdateManifest(
manifestPath,
notes,
notesPath,
previousCommit,
commits,
legacyManifest,
legacyManifestPath,
};
@@ -1,12 +1,5 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
@@ -16,18 +9,13 @@ import {
agcReleasePathPatterns,
buildRelease,
buildTauriBuildArguments,
collectRecentReleaseCommits,
collectReleaseCommits,
compareVersions,
createChannelConfig,
createLegacyUpdateManifest,
createUpdateManifest,
formatRecentReleaseNotes,
formatReleaseNotes,
generateUpdateManifest,
nextPatchVersion,
resolveManifestPlatformKeys,
resolvePreviousReleaseCommit,
resolveReleaseChannel,
resolveReleaseContext,
resolveReleasePartition,
@@ -897,9 +885,15 @@ for (const channel of ['release', 'beta-2']) {
assert.equal(result.channel, channel);
assert.equal(result.target, target);
assert.equal(result.manifest.version, packageVersion);
assert.equal(result.notes, '');
assert.equal(result.manifest.notes, undefined);
assert.equal(
readFileSync(result.notesPath, 'utf8'),
'(本次没有可用的更新摘要)\n',
);
assert.equal(result.legacyManifestPath, null);
assert.equal(result.legacyManifest, null);
assert.equal(requests.length, 2);
assert.equal(requests.length, 1);
for (const entry of [
...Object.values(result.manifest.platforms),
...Object.values(result.manifest.downloads),
@@ -973,96 +967,6 @@ test('version high water ignores the windows migration pointer for other channel
);
});
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', {
override: '6017d46088c04199e99cf89f347b12d67591475e',
}),
'6017d46088c04199e99cf89f347b12d67591475e',
);
// 覆盖值非法时忽略,继续用清单里的 commit。
assert.equal(
await resolvePreviousReleaseCommit('dev', {
override: 'not-a-sha',
}),
'abcdef1234567890',
);
assert.equal(
await resolvePreviousReleaseCommit('dev', { override: ' ' }),
'abcdef1234567890',
);
},
);
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev', { 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', { 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 entry forwards the built artifacts and dry-run mode to the uploader', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
@@ -1077,112 +981,6 @@ 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 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.equal(lines[0], '- 客户端更新切换到官方更新插件');
const truncatedSubject = lines[1].replace(/^- /u, '');
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
assert.match(truncatedSubject, /…$/u);
const many = formatReleaseNotes(
Array.from({ length: 20 }, (_, index) => ({
sha: `sha${index}`,
subject: `改动 ${index}`,
})),
);
assert.match(many, /- 其余 8 项客户端改动省略$/u);
assert.equal(
formatReleaseNotes([
{
sha: 'ignored',
subject: '提交标题\n不应展示的说明一\n不应展示的说明二',
},
]),
'- 提交标题',
);
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',
);
const commitMessagePath = path.join(
directory,
'.git',
'commit-message.txt',
);
writeFileSync(
commitMessagePath,
'客户端:新增更新插件接入\n补充更新插件接入的详细说明\n',
);
git('add', '.');
git('commit', '--quiet', '-F', commitMessagePath);
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);
// 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。
// 合并提交本身被 --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(
@@ -81,8 +81,6 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de
'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}',
// Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。
'"$HOME"/Library/Jenkins/agents/*/workspace/*',
// 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。
'refs/heads/master:refs/remotes/origin/master',
]) {
assert.ok(pipeline.includes(required), required);
}
@@ -87,6 +87,7 @@ export function AppUpdateNotice() {
</div>
<button
type="button"
className="app-update-download"
onClick={() => void handleDownload()}
disabled={isDownloading}
>
@@ -678,7 +678,7 @@ export function RuntimeConfigDialog({
const update = await checkForAppUpdate({ force: true });
setAppUpdateStatus(
update
? `发现新版本 v${update.version},可在右上角下载`
? `发现新版本 v${update.version},可在更新弹窗中下载`
: '当前已是最新版本',
);
} catch {
+23 -11
View File
@@ -30,41 +30,50 @@ body {
.app-update-notice {
position: fixed;
z-index: 40;
top: calc(var(--window-chrome-height) + 12px);
right: 16px;
top: 50%;
left: 50%;
display: flex;
align-items: center;
gap: 16px;
max-width: min(520px, calc(100vw - 32px));
padding: 12px 14px;
width: min(360px, calc(100vw - 32px));
flex-direction: column;
align-items: stretch;
gap: 14px;
padding: 24px 22px 20px;
border: 1px solid #efc9ae;
border-radius: 12px;
background: #fffaf5;
box-shadow: 0 8px 24px rgb(100 49 26 / 16%);
text-align: center;
transform: translate(-50%, -50%);
}
.app-update-notice > div {
display: grid;
gap: 3px;
justify-items: center;
gap: 5px;
min-width: 0;
}
.app-update-notice strong {
color: #4a220f;
font-size: 13px;
font-size: 15px;
}
.app-update-notice span,
.app-update-notice p {
margin: 0;
color: #8d6a58;
font-size: 11px;
font-size: 12px;
}
.app-update-notice p {
max-width: 320px;
max-height: 120px;
width: 100%;
max-height: 180px;
overflow-y: auto;
overflow-wrap: anywhere;
text-align: left;
white-space: pre-wrap;
}
.app-update-notice .app-update-download {
align-self: stretch;
padding: 9px 14px;
}
.app-update-notice button {
flex: 0 0 auto;
padding: 7px 12px;
@@ -81,6 +90,9 @@ body {
opacity: 0.65;
}
.app-update-notice .app-update-close {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -138,8 +138,8 @@
- 渠道由 `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`(每条 `- 提交标题`,标题只取 commit message 第一行并忽略后续说明行;最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes`归档文件 `release-notes.txt``AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段发布脚本用它定位下一次摘要的起点
- 更新摘要不再自动生成:发布脚本不读取提交记录生成 `notes`;只有 `AGC_UPDATE_RELEASE_NOTES` 非空时,才把显式手动文案写入渠道清单和旧协议清单的 `releaseNotes`。未设置时清单不携带更新说明,归档文件 `release-notes.txt` 记录“本次没有可用的更新摘要”
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段发布脚本只为线上排障保留源码 revision,不驱动更新摘要
- 上传:安装包与 `.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 跨架构合并仍遵循现有单架构发布约束。
- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
@@ -177,7 +177,7 @@
| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) |
| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48`downloadUrl` 指向同一对象,含 `sha256` / `size`) |
| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) |
| 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) |
| 更新摘要按显式配置写入 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(未设置 `AGC_UPDATE_RELEASE_NOTES`渠道清单不写入 `notes` |
渠道安装身份隔离已于 `2026-09-21` 完成源码验收:
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@ pipeline {
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 构建 WindowsmacOS 在对应构建机执行')
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则本次发布的客户端相关提交自动生成更新摘要')
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则本次发布不携带更新摘要')
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与持久收件人凭据合并发送')
}
@@ -170,25 +170,6 @@ pipeline {
stage('Build and upload') {
steps {
script {
// 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道),
// 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空,
// 发布脚本会退回清单锚点或干脆不写摘要。
def anchor = ''
try {
def previousBuild = currentBuild.previousSuccessfulBuild
def previousChannel = (previousBuild?.buildVariables?.AGC_UPDATE_CHANNEL ?: '').toString().trim()
if (previousChannel == params.AGC_UPDATE_CHANNEL.trim()) {
anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
}
} catch (error) {
echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}"
}
env.AGC_UPDATE_PREVIOUS_COMMIT = anchor
if (anchor) {
echo "更新摘要锚点兜底:${anchor.take(12)}"
}
}
withCredentials([
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'),
@@ -202,7 +183,6 @@ pipeline {
"AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}",
'AGC_BUILD_TARGET=x86_64-pc-windows-msvc',
"AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
"AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}",
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
]) {
powershell '''
@@ -16,7 +16,7 @@ pipeline {
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选三段版本号;留空则按 <channel>-mac 分区清单高水位递增 patch。首次发布建议显式指定,避免版本链回退')
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '默认直接发布到 <channel>-mac 分区;勾选后只构建、验签并打印将上传的对象,不写 OSS(演练)')
booleanParam(name: 'SKIP_IF_SUPERSEDED', defaultValue: false, description: '置真时:本次 COMMIT_HASH 若已被源码分支推进,则直接跳过而不构建。调度器触发本 Job 时置真,避免节点离线期间排队的旧构建在恢复后发布过期版本')
string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选单行更新摘要;留空则由发布脚本按提交自动汇总')
string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选更新摘要;留空则本次发布不携带更新摘要')
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 命令名或绝对路径(Mac 节点默认装在 ~/.local/bin/ossutil')
string(name: 'CARGO_BUILD_JOBS', defaultValue: '8', description: '并行 rustc 任务数,默认吃满节点 8 核(4P+4E)。该值同时作为 rustc codegen 的 jobserver 令牌上限;节点只有 24 GB 内存且是日常办公机,若构建期间出现明显换页可临时调低。只影响本次构建')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加邮件通知收件人;会与持久收件人凭据合并发送')
@@ -69,10 +69,6 @@ pipeline {
fi
test "$(git remote get-url origin)" = "$GIT_REMOTE_URL"
git fetch --no-tags origin "+refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"
# 同时取 master:渠道清单里的上一次发布 commit 落在 master 上,缺了它更新摘要会退化成
# 「最近客户端改动」。这一步只是摘要质量,失败不阻断发布。
git fetch --no-tags origin "+refs/heads/master:refs/remotes/origin/master" ||
echo '[agc-macos] 拉取 master 失败:本次更新摘要可能退化为最近提交列表。'
ref="refs/remotes/origin/$SOURCE_BRANCH"
if [ -n "$COMMIT_HASH" ]; then
case "$COMMIT_HASH" in *[!0-9a-fA-F]* ) echo 'COMMIT_HASH 必须为十六进制'; exit 1;; esac