Merge remote-tracking branch 'origin/master' into fix/agc-recent-project-status-retry
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);
|
||||
}
|
||||
|
||||
@@ -27,15 +27,7 @@ pub(crate) fn export_local_project_package_at(
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
validate_project_root(root)?;
|
||||
super::verification::validate_project_game_entry(root)?;
|
||||
ensure_project_export_package_dir(root, "exports")?;
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
if !readme_path.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||||
if !readme_metadata.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
ensure_project_export_readme(root)?;
|
||||
|
||||
let files = collect_project_export_package_files(root)?;
|
||||
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
|
||||
@@ -140,6 +132,20 @@ pub(crate) fn export_local_project_package_at(
|
||||
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
|
||||
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
|
||||
|
||||
fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
|
||||
let has_completed_prototype_task = manifest.tasks.iter().any(|task| {
|
||||
task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
|
||||
});
|
||||
let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| {
|
||||
preview.status == GameCreationAppPreviewStatus::Running
|
||||
&& preview
|
||||
.url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
});
|
||||
has_completed_prototype_task || has_running_preview
|
||||
}
|
||||
|
||||
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
|
||||
///
|
||||
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
|
||||
@@ -256,6 +262,12 @@ pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
if validate_project_game_entry(root).is_ok() {
|
||||
return export_local_project_package_at(root);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
if !project_has_runnable_prototype(&manifest) {
|
||||
return Err(
|
||||
"首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(),
|
||||
);
|
||||
}
|
||||
let plan = resolve_publish_build_plan(root)?;
|
||||
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
|
||||
// 否则作者要点两次(先 bootstrap 再发布)。
|
||||
@@ -477,6 +489,54 @@ pub(crate) fn list_local_project_export_packages_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 发布导出前确保 `exports/README.md` 存在。
|
||||
///
|
||||
/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。
|
||||
/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。
|
||||
fn ensure_project_export_readme(root: &Path) -> Result<PathBuf, String> {
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
match fs::symlink_metadata(&readme_path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("试玩包不能包含符号链接:exports/README.md".to_string());
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err("exports/README.md 必须是普通文件".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let content = render_project_export_readme(&manifest);
|
||||
write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 exports/README.md 元数据失败:{}: {error}",
|
||||
readme_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(readme_path)
|
||||
}
|
||||
|
||||
fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String {
|
||||
let title = manifest.name.trim();
|
||||
let title = if title.is_empty() {
|
||||
"试玩游戏"
|
||||
} else {
|
||||
title
|
||||
};
|
||||
let summary = manifest
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。");
|
||||
format!(
|
||||
"# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_project_export_package_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||||
|
||||
@@ -4018,6 +4018,12 @@ writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild
|
||||
"#,
|
||||
)
|
||||
.expect("write build script");
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
GameCreationAppTaskStatus::Completed,
|
||||
)
|
||||
.expect("mark prototype completed");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
@@ -4045,13 +4051,21 @@ async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
.expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("已有可玩入口时直接导出");
|
||||
.expect("已有可玩入口且缺少 README 时应自动生成并直接导出");
|
||||
|
||||
assert!(result.package_relative_path.ends_with(".zip"));
|
||||
let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme");
|
||||
assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}");
|
||||
let payload = read_local_project_export_package_at(&root, &result.package_relative_path)
|
||||
.expect("read publishable package");
|
||||
assert!(payload.files.iter().any(|file| file.path == "index.html"));
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "exports/README.md"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
@@ -4061,11 +4075,56 @@ async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_blocks_build_until_runnable_prototype_is_completed() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-prototype-pending", "原型未完成项目")
|
||||
.expect("project init");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-prototype-pending-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-should-not-run.mjs" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
fs::write(
|
||||
root.join("build-should-not-run.mjs"),
|
||||
"import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');",
|
||||
)
|
||||
.expect("write build marker script");
|
||||
|
||||
let error = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect_err("原型未完成时禁止触发用户项目构建");
|
||||
|
||||
assert!(
|
||||
error.contains("首个可运行原型尚未完成"),
|
||||
"错误应说明原型未完成:{error}"
|
||||
);
|
||||
assert!(!root.join("build-ran.txt").exists());
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
"原型未完成时不应执行项目构建:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
|
||||
.expect("project init");
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
GameCreationAppTaskStatus::Completed,
|
||||
)
|
||||
.expect("mark prototype completed");
|
||||
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
@@ -4103,6 +4162,10 @@ fn local_project_export_package_publish_payload_contains_bytes_and_file_digests(
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let exported = export_local_project_package_at(&root).expect("export package");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("exports/README.md")).unwrap(),
|
||||
"publish notes"
|
||||
);
|
||||
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
|
||||
.expect("read publish payload");
|
||||
|
||||
@@ -4251,18 +4314,29 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_requires_playable_html_and_readme() {
|
||||
fn local_project_export_package_generates_missing_readme_after_playable_html() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
let missing_readme =
|
||||
let missing_entry =
|
||||
export_local_project_package_at(&root).expect_err("default html is not playable");
|
||||
assert!(missing_readme.contains("游戏入口必须包含可渲染画布"));
|
||||
assert!(missing_entry.contains("游戏入口必须包含可渲染画布"));
|
||||
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
let missing_readme =
|
||||
export_local_project_package_at(&root).expect_err("readme should be required");
|
||||
assert!(missing_readme.contains("exports/README.md"));
|
||||
fs::remove_dir_all(root.join("exports")).expect("remove exports dir");
|
||||
|
||||
let exported =
|
||||
export_local_project_package_at(&root).expect("missing readme should be generated");
|
||||
let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme");
|
||||
assert!(readme.contains("# 像素动作原型 试玩说明"), "{readme}");
|
||||
assert!(readme.contains("解压试玩包后打开根目录"), "{readme}");
|
||||
|
||||
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
|
||||
.expect("read generated package");
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "exports/README.md"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ import type {
|
||||
TauriInvoke,
|
||||
} from './app/types';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import {
|
||||
GamePublishProgressDialog,
|
||||
type GamePublishProgressState,
|
||||
} from './components/game-distribution/GamePublishProgressDialog';
|
||||
import {
|
||||
agentConversationId,
|
||||
agentRuntimeStateFromResult,
|
||||
@@ -368,6 +372,8 @@ export function App({
|
||||
const [publishPackageResult, setPublishPackageResult] =
|
||||
useState<LocalProjectExportPackageResult | null>(null);
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
const [publishProgress, setPublishProgress] =
|
||||
useState<GamePublishProgressState | null>(null);
|
||||
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
|
||||
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
|
||||
const [projectChatError, setProjectChatError] = useState('');
|
||||
@@ -1126,40 +1132,6 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
async function queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke: TauriInvoke,
|
||||
commandId: GameCreationAppCommandDescriptor['id'],
|
||||
projectPath: string,
|
||||
detail: string,
|
||||
readyMessage: string,
|
||||
onConfirm: () => void,
|
||||
) {
|
||||
const policyView = await invoke<ProjectPermissionPolicyView>(
|
||||
'read_project_permission_policy',
|
||||
{ projectPath },
|
||||
);
|
||||
if (policyView.policy.deniedCommands.includes(commandId)) {
|
||||
const message = `项目权限策略拒绝执行:${commandId}`;
|
||||
markProjectPolicyDenied(commandId, message);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return true;
|
||||
}
|
||||
if (!policyView.policy.confirmCommands.includes(commandId)) {
|
||||
return false;
|
||||
}
|
||||
requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: readyMessage },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(readyMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function denyPendingCommandIfNeeded(
|
||||
commandId: GameCreationAppCommandDescriptor['id'],
|
||||
projectPath: string | null,
|
||||
@@ -1206,8 +1178,8 @@ export function App({
|
||||
/**
|
||||
* 导出试玩包并打开发布面板。
|
||||
*
|
||||
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
|
||||
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
|
||||
* 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区,
|
||||
* 导出失败在弹窗内回显;成功后才切换到发布资料面板。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
@@ -1232,52 +1204,42 @@ export function App({
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
// 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。
|
||||
announcePublishMessage('正在检查发布权限…');
|
||||
const runExport = async () => {
|
||||
// 导出可能包含构建步骤,再回一条即时反馈。
|
||||
announcePublishMessage('正在构建并打包试玩包,请稍候…');
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
announcePublishMessage(
|
||||
`已构建并打包试玩包:${result.packageRelativePath}`,
|
||||
);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
nextProjectPath,
|
||||
'command.auto',
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
announcePublishMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const queued = await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
'project.export_package',
|
||||
nextProjectPath,
|
||||
'导出试玩包并打开「发布到游戏广场」面板。',
|
||||
'导出试玩包需要确认,确认后继续。',
|
||||
() => void runExport(),
|
||||
);
|
||||
if (!queued) {
|
||||
await runExport();
|
||||
}
|
||||
} catch (error) {
|
||||
// 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到
|
||||
// 「点了没反应」,这里把它收敛成聊天里的可读错误。
|
||||
const publishManifest = manifestRef.current;
|
||||
const hasCompletedPrototype = publishManifest.tasks.some(
|
||||
(task) => task.id === 'code-prototype' && task.status === 'completed',
|
||||
);
|
||||
const hasRunningPreview =
|
||||
publishManifest.preview?.status === 'running' &&
|
||||
Boolean(publishManifest.preview.url?.trim());
|
||||
if (!hasCompletedPrototype && !hasRunningPreview) {
|
||||
announcePublishMessage(
|
||||
`发布前权限检查失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPublishProgress({
|
||||
phase: 'running',
|
||||
message: '正在构建并打包试玩包,请稍候…',
|
||||
});
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setPublishProgress(null);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
nextProjectPath,
|
||||
'command.auto',
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
setPublishProgress({
|
||||
phase: 'failed',
|
||||
message: '发布失败',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1723,20 +1685,40 @@ export function App({
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const paused = await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
const policyView = await invoke<ProjectPermissionPolicyView>(
|
||||
'read_project_permission_policy',
|
||||
{ projectPath: input.projectPath },
|
||||
);
|
||||
if (policyView.policy.deniedCommands.includes('conversation.write')) {
|
||||
const message = '项目权限策略拒绝执行:conversation.write';
|
||||
markProjectPolicyDenied('conversation.write', message);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return false;
|
||||
}
|
||||
if (!policyView.policy.confirmCommands.includes('conversation.write')) {
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
return true;
|
||||
}
|
||||
requestProjectPolicyConfirmation(
|
||||
'conversation.write',
|
||||
input.projectPath,
|
||||
'写入 DirectProject 对话历史',
|
||||
'DirectProject 对话写入需要确认。',
|
||||
() => {
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
input.onConfirmed();
|
||||
},
|
||||
);
|
||||
if (paused) return false;
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
return true;
|
||||
const message = 'DirectProject 对话写入需要确认。';
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current === input.projectPath) {
|
||||
setProjectChatError(
|
||||
@@ -2351,6 +2333,10 @@ export function App({
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2502,6 +2488,10 @@ export function App({
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export function AppUpdateNotice() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-update-download"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { CircleAlert, LoaderCircle } from 'lucide-react';
|
||||
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
export type GamePublishProgressState =
|
||||
| {
|
||||
phase: 'running';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
phase: 'failed';
|
||||
message: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export function GamePublishProgressDialog({
|
||||
progress,
|
||||
onClose,
|
||||
}: {
|
||||
progress: GamePublishProgressState | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const failed = progress?.phase === 'failed';
|
||||
return (
|
||||
<ThemedModal
|
||||
open={progress !== null}
|
||||
ariaLabel={failed ? '发布失败' : '发布进度'}
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={failed}
|
||||
overlayClassName="game-publish-progress-overlay"
|
||||
panelClassName="game-publish-progress-dialog"
|
||||
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
|
||||
>
|
||||
<div
|
||||
className={`game-publish-progress-icon${failed ? ' is-failed' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{failed ? (
|
||||
<CircleAlert size={30} />
|
||||
) : (
|
||||
<LoaderCircle className="is-spinning" size={30} />
|
||||
)}
|
||||
</div>
|
||||
<h2>{failed ? '发布失败' : '正在发布'}</h2>
|
||||
<p role={failed ? 'alert' : 'status'}>
|
||||
{failed ? progress.detail : progress?.message}
|
||||
</p>
|
||||
{failed ? (
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
) : (
|
||||
<small>发布完成前请保持客户端开启,页面暂时不可操作。</small>
|
||||
)}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -9588,6 +9600,88 @@ iframe.preview-frame {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-publish-progress-overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
background: rgb(35 24 19 / 62%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog {
|
||||
display: grid;
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
outline: none;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 28px;
|
||||
border: 1px solid #e4c8ba;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 24px 64px rgb(62 37 27 / 28%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog h2,
|
||||
.game-publish-progress-dialog p,
|
||||
.game-publish-progress-dialog small {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog p,
|
||||
.game-publish-progress-dialog small {
|
||||
color: #8f7367;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog p[role='alert'] {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
color: #a33f2a;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: #f6dfd0;
|
||||
color: #c7653d;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon.is-failed {
|
||||
background: #f8d8d0;
|
||||
color: #b43c25;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon .is-spinning {
|
||||
animation: app-update-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog button {
|
||||
min-width: 96px;
|
||||
padding: 8px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: #c7653d;
|
||||
box-shadow: 0 0 0 0 rgb(199 101 61 / 0%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog button:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgb(199 101 61 / 28%);
|
||||
}
|
||||
|
||||
.game-approval-backdrop {
|
||||
position: fixed;
|
||||
top: var(--window-chrome-height);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
/**
|
||||
* 客户端「发布」入口的可见反馈。
|
||||
*
|
||||
* DirectProject 项目不发工作台状态行,发布动作的提示必须回到项目对话;
|
||||
* 权限要求确认时也要在聊天里给出确认/取消,否则表现就是「点了没反应」。
|
||||
* 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片;
|
||||
* 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { beforeEach, describe, it, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -20,6 +22,12 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
import { repoPath } from './repoPath';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
resolveDeclarations,
|
||||
} from './styleCascade';
|
||||
|
||||
const readGamePublishAvailabilityMock = vi.hoisted(() =>
|
||||
vi.fn(async () => true),
|
||||
@@ -39,18 +47,41 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
const PROJECT_PATH = '/tmp/game-publish-feedback-project';
|
||||
const PROJECT_ID = 'game-publish-feedback-project';
|
||||
|
||||
function withPrototypeStatus(
|
||||
manifest: GameCreationAppManifest,
|
||||
status: 'pending' | 'completed',
|
||||
): GameCreationAppManifest {
|
||||
return {
|
||||
...manifest,
|
||||
tasks: manifest.tasks.map((task) =>
|
||||
task.id === 'code-prototype' ? { ...task, status } : task,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createFixtureManifest(): GameCreationAppManifest {
|
||||
return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目');
|
||||
return withPrototypeStatus(
|
||||
createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'),
|
||||
'completed',
|
||||
);
|
||||
}
|
||||
|
||||
function createPendingPrototypeManifest(): GameCreationAppManifest {
|
||||
return withPrototypeStatus(
|
||||
createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'),
|
||||
'pending',
|
||||
);
|
||||
}
|
||||
|
||||
function installTauri(
|
||||
options: {
|
||||
exportPackage?: () => unknown;
|
||||
manifest?: GameCreationAppManifest;
|
||||
policy?: ReturnType<typeof emptyProjectPolicy>;
|
||||
readPolicy?: () => unknown;
|
||||
} = {},
|
||||
) {
|
||||
const manifest = createFixtureManifest();
|
||||
const manifest = options.manifest ?? createFixtureManifest();
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
@@ -82,12 +113,11 @@ function installTauri(
|
||||
return { invoke };
|
||||
}
|
||||
|
||||
function renderPublishProject() {
|
||||
function renderPublishProject(
|
||||
manifest: GameCreationAppManifest = createFixtureManifest(),
|
||||
) {
|
||||
return render(
|
||||
<App
|
||||
initialProjectManifest={createFixtureManifest()}
|
||||
initialProjectPath={PROJECT_PATH}
|
||||
/>,
|
||||
<App initialProjectManifest={manifest} initialProjectPath={PROJECT_PATH} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,76 +155,115 @@ function createConfirmPolicy() {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('客户端发布入口的可见反馈', () => {
|
||||
it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => {
|
||||
installTauri({ exportPackage: createExportPackageResult });
|
||||
renderPublishProject();
|
||||
it('首个可运行原型未完成时直接阻止发布,不触发用户项目构建', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
const manifest = createPendingPrototypeManifest();
|
||||
installTauri({ exportPackage, manifest });
|
||||
|
||||
renderPublishProject(manifest);
|
||||
|
||||
const surface = await clickPublish();
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain('正在检查发布权限…');
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'已构建并打包试玩包:exports/game.zip',
|
||||
'首个可运行原型尚未完成,暂不能发布',
|
||||
);
|
||||
});
|
||||
expect(surface.textContent ?? '').not.toContain('正在检查发布权限…');
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
|
||||
const deferred =
|
||||
createDeferred<ReturnType<typeof createExportPackageResult>>();
|
||||
const exportPackage = vi.fn(() => deferred.promise);
|
||||
installTauri({ exportPackage });
|
||||
renderPublishProject();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
|
||||
const progressDialog = await screen.findByRole('dialog', {
|
||||
name: '发布进度',
|
||||
});
|
||||
expect(progressDialog.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(progressDialog.textContent ?? '').toContain(
|
||||
'发布完成前请保持客户端开启,页面暂时不可操作。',
|
||||
);
|
||||
expect(
|
||||
progressDialog.closest('.game-publish-progress-overlay'),
|
||||
).not.toBeNull();
|
||||
expect(exportPackage).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
|
||||
|
||||
deferred.resolve(createExportPackageResult());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '发布到游戏广场' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('导出失败时把可读错误写回项目对话', async () => {
|
||||
it('发布失败时在进度弹窗里显示错误并允许关闭', async () => {
|
||||
installTauri({
|
||||
exportPackage: () => {
|
||||
throw new Error('导出试玩包前需要先生成 exports/README.md');
|
||||
throw new Error('构建可玩版本失败:缺少入口');
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
const surface = await clickPublish();
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发布失败',
|
||||
});
|
||||
expect(failureDialog.textContent ?? '').toContain(
|
||||
'构建可玩版本失败:缺少入口',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(failureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'导出试玩包前需要先生成 exports/README.md',
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
installTauri({ exportPackage, policy: createConfirmPolicy() });
|
||||
renderPublishProject();
|
||||
|
||||
const publish = await screen.findByRole('button', {
|
||||
name: '发布到游戏广场',
|
||||
});
|
||||
fireEvent.click(publish);
|
||||
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText(
|
||||
'导出试玩包并打开「发布到游戏广场」面板。',
|
||||
it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => {
|
||||
const rules = parseStyleSheet(
|
||||
readFileSync(
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'已构建并打包试玩包:exports/game.zip',
|
||||
const overlay = resolveDeclarations(
|
||||
rules,
|
||||
['.game-publish-progress-overlay'],
|
||||
1440,
|
||||
);
|
||||
expect(declaration(overlay, 'position')).toBe('fixed');
|
||||
expect(declaration(overlay, 'inset')).toBe('0');
|
||||
expect(declaration(overlay, 'z-index')).toBe('500');
|
||||
expect(declaration(overlay, 'pointer-events')).toBe('auto');
|
||||
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
|
||||
});
|
||||
|
||||
it('取消权限确认时把取消结果写回项目对话', async () => {
|
||||
it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
installTauri({ exportPackage, policy: createConfirmPolicy() });
|
||||
renderPublishProject();
|
||||
@@ -202,71 +271,9 @@ describe('客户端发布入口的可见反馈', () => {
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '取消' }),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain('已取消导出本地试玩包');
|
||||
});
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
let denyOnNextPolicyRead = false;
|
||||
installTauri({
|
||||
exportPackage,
|
||||
readPolicy: () => {
|
||||
if (!denyOnNextPolicyRead) return createConfirmPolicy();
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: {
|
||||
deniedCommands: ['project.export_package'],
|
||||
confirmCommands: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
denyOnNextPolicyRead = true;
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'项目权限策略拒绝执行:project.export_package',
|
||||
);
|
||||
});
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('权限查询失败时也在项目对话里回显错误', async () => {
|
||||
installTauri({
|
||||
readPolicy: () => {
|
||||
throw new Error('策略文件损坏');
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
const surface = await clickPublish();
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'发布前权限检查失败:策略文件损坏',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('project.export_package')).toBeNull();
|
||||
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-23 AGC 发布前先守可运行原型门禁
|
||||
|
||||
- 背景:客户端已经显示“首个可运行原型尚未完成,运行视图暂不可用”,但发布入口仍会先执行用户项目的 `build`,导致未完成原型也进入构建并在后续失败。
|
||||
- 决策:`project.export_package` 的发布专用导出链路先检查可玩入口;没有入口时,只有 `code-prototype` 已完成或存在运行中的预览才允许执行 `build`,否则直接返回“首个可运行原型尚未完成,暂不能发布”。发布不再进入聊天确认卡,改为独立全屏进度弹窗;运行中遮罩覆盖整个工作区并阻止交互,失败留在弹窗内,成功后切换到发布资料面板。
|
||||
- 边界:已有可运行入口仍直接打包;原型已完成但缺构建产物时保留原有自动构建;缺失 `exports/README.md` 仍在导出前自动生成。
|
||||
- 验证:Rust `publish_export` 4/4、前端发布相关测试 16/16、AGC `tsc`、编码检查和 `git diff --check` 通过。
|
||||
|
||||
## 2026-09-22 Direct 埋点与业务持久化锁隔离
|
||||
|
||||
- Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。
|
||||
|
||||
@@ -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` 完成源码验收:
|
||||
|
||||
|
||||
@@ -1334,7 +1334,7 @@ game-project/
|
||||
- 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。
|
||||
- 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。
|
||||
- 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并切换到客户端内运行视图;该命令不开放任意 shell。
|
||||
- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;导出前重新校验 `game/index.html` 是可试玩自包含 HTML,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。
|
||||
- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;缺少 `exports/README.md` 时先按项目 manifest 生成最小试玩说明,已有文件原样保留。发布前若 `code-prototype` 未完成且没有运行中的预览,直接阻止发布,不触发用户项目构建;可运行原型完成后才允许按 `build` 脚本补齐产物。发布进度使用独立模态弹窗展示,遮罩覆盖整个工作区并阻止交互,不再使用聊天确认卡;导出前重新校验可玩入口,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。
|
||||
- 聊天输入 `/exports` 会只读执行 `project.export_list`,列出当前项目 `exports/playtest-package-*.zip` 历史试玩包,并提供显示目录或继续 `/export` 的草稿;该命令不删除文件、不分享文件、不新增面板。
|
||||
- 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并切换到客户端内运行视图;`/open-preview` 在本地项目已初始化后生成待确认的 `preview.open`,只激活当前授权项目对应的 `127.0.0.1` 运行容器;`/preview-status` 只查询当前授权项目的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不展示或停止其它项目遗留的全局预览。
|
||||
- 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;主窗口“记到黑板”“覆盖黑板”“清空黑板”只填入 `/remember blackboard `、`/memory-set blackboard ` 或 `/forget-memory blackboard` 草稿,仍由用户补内容并走聊天确认;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。
|
||||
|
||||
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