调整 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
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:
@@ -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 {
|
||||
|
||||
@@ -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` 显式指定,默认 dev;Windows 与 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 构建 Windows,macOS 在对应构建机执行')
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user