Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3682fd06f | |||
| 33f5ad68bf | |||
| 5bf036bb81 | |||
| 5383af4cc5 | |||
| 47f7cb2bf1 | |||
| 603650fbf4 | |||
| 5d20f5bfeb | |||
| af38de9fd1 | |||
| 18252e24b8 | |||
| 1ce2c10107 | |||
| 48caa23997 | |||
| ea141d9f17 | |||
| 769740a46d | |||
| b472b9eec9 | |||
| 0d5940433d | |||
| e5fd454bb3 | |||
| 03d7bca9a5 | |||
| 2abad3b537 | |||
| fc5cea43ac | |||
| c2ab9674a0 | |||
| 72cd0747a4 | |||
| 3d4c7929c3 | |||
| 8dce016552 | |||
| 64879cfcdd | |||
| 6853ec5388 | |||
| 796ad7bb51 | |||
| 20b34918d3 | |||
| 04e5512777 | |||
| 0510cd8d9c | |||
| 7a237431e0 | |||
| 6ed3772ff5 | |||
| a86ead93e4 | |||
| b0ce384b8d | |||
| b56566f40e | |||
| 6782d0ea23 | |||
| e6092dda6f | |||
| 5f13698129 | |||
| 29ba9a358e | |||
| 3ab56e6cfa | |||
| 56c4faa8ee | |||
| d23c808376 | |||
| 41930b858e | |||
| 9dccf5318e | |||
| f216381619 | |||
| d454d3fa4b | |||
| a5ee45aa4e | |||
| d333f783fe | |||
| faab318127 | |||
| a4296a4bdd | |||
| 7eee89ded3 | |||
| f9bc2d917c | |||
| b96cd4b7c2 | |||
| f185d21f55 | |||
| a61b0cce81 | |||
| 2a75a2d2c8 | |||
| 512f669f57 | |||
| 21dbbb4c3c | |||
| 55d0c99e2b | |||
| 3907a8bf74 | |||
| 46dfa2cffb | |||
| 97e2b26900 | |||
| 39e33f7a5b | |||
| 040f646480 | |||
| 8b007c637d | |||
| f0a62c6306 | |||
| 30edfee00e | |||
| c2da8e184e | |||
| a19319d815 | |||
| d07ea186fa | |||
| 80caec373a | |||
| 3caefd6716 | |||
| 4d0dd2d721 | |||
| 2befaaef9d | |||
| 20b0bf4dd7 | |||
| b4fd8d9b8b | |||
| fe85fa2a62 | |||
| b14a533b17 | |||
| 42969b2218 | |||
| c4ee75a1af | |||
| 6f2137cbb0 | |||
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -23,3 +23,6 @@
|
|||||||
*.meta text
|
*.meta text
|
||||||
*.anim text
|
*.anim text
|
||||||
*.controller text
|
*.controller text
|
||||||
|
|
||||||
|
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||||
|
packages/shared/src/contracts/generated/** linguist-generated=true
|
||||||
|
|||||||
@@ -221,8 +221,7 @@ jobs:
|
|||||||
- name: Run AI game creator shell agent-run smoke
|
- name: Run AI game creator shell agent-run smoke
|
||||||
run: npm run check:native-shells:agc-rust-smoke
|
run: npm run check:native-shells:agc-rust-smoke
|
||||||
|
|
||||||
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
|
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
||||||
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
|
|
||||||
ai-game-creator-shell-rust-crates:
|
ai-game-creator-shell-rust-crates:
|
||||||
name: AI game creator shell Rust crates
|
name: AI game creator shell Rust crates
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -263,13 +262,15 @@ jobs:
|
|||||||
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
||||||
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
||||||
# (见 #327 / PR #316 run 1950)。
|
# (见 #327 / PR #316 run 1950)。
|
||||||
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
|
||||||
|
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||||
# 解析,不再触碰 registry index。
|
# 解析,不再触碰 registry index。
|
||||||
for manifest_path in \
|
for manifest_path in \
|
||||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
|
||||||
|
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
|
||||||
for attempt in $(seq 1 5); do
|
for attempt in $(seq 1 5); do
|
||||||
if cargo fetch \
|
if cargo fetch \
|
||||||
--target x86_64-unknown-linux-gnu \
|
--target x86_64-unknown-linux-gnu \
|
||||||
@@ -284,6 +285,23 @@ jobs:
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Prepare Unity plugin Rust dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if cargo fetch --locked \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell shared crate gates
|
- name: Run AI game creator shell shared crate gates
|
||||||
run: npm run check:native-shells:agc-rust-crates
|
run: npm run check:native-shells:agc-rust-crates
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ temp*build*/
|
|||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
|
/plugins/agc-unity-editor/dotnet/**/bin/
|
||||||
|
/plugins/agc-unity-editor/dotnet/**/obj/
|
||||||
|
/plugins/agc-unity-editor/dotnet/publish/
|
||||||
|
/plugins/agc-unity-editor/dotnet/native-build/
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,14 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
||||||
|
"options": {
|
||||||
|
"printWidth": 1000,
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -676,11 +676,11 @@ async function runSelfTest() {
|
|||||||
designFoundationAssetCall?.arguments?.input?.outputPath ===
|
designFoundationAssetCall?.arguments?.input?.outputPath ===
|
||||||
'assets/ui-prototype.png' &&
|
'assets/ui-prototype.png' &&
|
||||||
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
|
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
|
||||||
designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
|
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' &&
|
||||||
artAssetPlanAssetCall?.arguments?.input?.outputPath ===
|
artAssetPlanAssetCall?.arguments?.input?.outputPath ===
|
||||||
'assets/art-spritesheet.png' &&
|
'assets/art-spritesheet.png' &&
|
||||||
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
|
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
|
||||||
artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
|
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet',
|
||||||
'self-test-visual-assets-invalid',
|
'self-test-visual-assets-invalid',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [
|
|||||||
{ path: 'game/balance.json', kind: 'json' },
|
{ path: 'game/balance.json', kind: 'json' },
|
||||||
{ path: 'assets/manifest.art.json', kind: 'json' },
|
{ path: 'assets/manifest.art.json', kind: 'json' },
|
||||||
{ path: 'assets/manifest.audio.json', kind: 'json' },
|
{ path: 'assets/manifest.audio.json', kind: 'json' },
|
||||||
{ path: 'game/index.html', kind: 'game-entry' },
|
{ path: 'game/index.html', kind: 'file' },
|
||||||
{ path: 'exports/README.md', kind: 'file' },
|
{ path: 'exports/README.md', kind: 'file' },
|
||||||
];
|
];
|
||||||
const editorImageArtifactSpecs = [
|
const editorImageArtifactSpecs = [
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const agcReleasePathPatterns = [
|
|||||||
'packages/',
|
'packages/',
|
||||||
'server-rs/crates/',
|
'server-rs/crates/',
|
||||||
'plugins/agc-cocos-editor/',
|
'plugins/agc-cocos-editor/',
|
||||||
|
'plugins/agc-unity-editor/',
|
||||||
'apps/desktop-shell/src-tauri/icons/',
|
'apps/desktop-shell/src-tauri/icons/',
|
||||||
'package.json',
|
'package.json',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
@@ -500,6 +501,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectFirstInstallArtifact(
|
||||||
|
files,
|
||||||
|
{ target, version, artifact },
|
||||||
|
) {
|
||||||
|
validateReleaseTarget(target);
|
||||||
|
let selected;
|
||||||
|
if (target.includes('windows')) {
|
||||||
|
selected = artifact;
|
||||||
|
if (!selected?.endsWith('.exe')) {
|
||||||
|
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||||||
|
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||||
|
const suffix = `_${version}_${architecture}.dmg`;
|
||||||
|
const candidates = files.filter((file) =>
|
||||||
|
path.basename(file).endsWith(suffix),
|
||||||
|
);
|
||||||
|
if (candidates.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length} 个`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selected = candidates[0];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!fs.existsSync(selected) ||
|
||||||
|
!fs.statSync(selected).isFile() ||
|
||||||
|
fs.statSync(selected).size === 0
|
||||||
|
) {
|
||||||
|
throw new Error(`首装包不存在或为空:${selected}`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
function readUpdaterSignature(artifactPath) {
|
function readUpdaterSignature(artifactPath) {
|
||||||
const signaturePath = `${artifactPath}.sig`;
|
const signaturePath = `${artifactPath}.sig`;
|
||||||
if (!fs.existsSync(signaturePath)) {
|
if (!fs.existsSync(signaturePath)) {
|
||||||
@@ -520,23 +556,32 @@ export function createUpdateManifest(
|
|||||||
publishedAt = new Date().toISOString(),
|
publishedAt = new Date().toISOString(),
|
||||||
notes = readReleaseNotes(),
|
notes = readReleaseNotes(),
|
||||||
commit = readHeadCommit(),
|
commit = readHeadCommit(),
|
||||||
|
downloadArtifact,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
validateReleaseTarget(target);
|
validateReleaseTarget(target);
|
||||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
|
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
|
||||||
const signature = readUpdaterSignature(artifactPath);
|
const signature = readUpdaterSignature(artifactPath);
|
||||||
const version = readPackageJson().version;
|
const version = readPackageJson().version;
|
||||||
|
const firstInstallArtifact = selectFirstInstallArtifact(
|
||||||
|
downloadArtifact ? [downloadArtifact] : [],
|
||||||
|
{ target, version, artifact: artifactPath },
|
||||||
|
);
|
||||||
const fileName = path.basename(artifactPath);
|
const fileName = path.basename(artifactPath);
|
||||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||||
|
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||||||
const platforms = {};
|
const platforms = {};
|
||||||
|
const downloads = {};
|
||||||
for (const key of resolveManifestPlatformKeys(target)) {
|
for (const key of resolveManifestPlatformKeys(target)) {
|
||||||
platforms[key] = { signature, url };
|
platforms[key] = { signature, url };
|
||||||
|
downloads[key] = { url: downloadUrl };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
pub_date: publishedAt,
|
pub_date: publishedAt,
|
||||||
platforms,
|
platforms,
|
||||||
|
downloads,
|
||||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||||
...(commit ? { commit } : {}),
|
...(commit ? { commit } : {}),
|
||||||
};
|
};
|
||||||
@@ -675,10 +720,16 @@ export async function generateUpdateManifest(
|
|||||||
context = resolveReleaseContext(),
|
context = resolveReleaseContext(),
|
||||||
) {
|
) {
|
||||||
const { channel, target, bundleRoot } = context;
|
const { channel, target, bundleRoot } = context;
|
||||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
|
const files = listFiles(bundleRoot);
|
||||||
|
const artifact = selectReleaseArtifact(files, target);
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||||
}
|
}
|
||||||
|
const downloadArtifact = selectFirstInstallArtifact(files, {
|
||||||
|
target,
|
||||||
|
version: readPackageJson().version,
|
||||||
|
artifact,
|
||||||
|
});
|
||||||
const manualNotes = readReleaseNotes();
|
const manualNotes = readReleaseNotes();
|
||||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||||||
const commits = collectReleaseCommits(previousCommit);
|
const commits = collectReleaseCommits(previousCommit);
|
||||||
@@ -692,7 +743,12 @@ export async function generateUpdateManifest(
|
|||||||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const manifest = createUpdateManifest(artifact, { channel, target, notes });
|
const manifest = createUpdateManifest(artifact, {
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
notes,
|
||||||
|
downloadArtifact,
|
||||||
|
});
|
||||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||||||
@@ -717,6 +773,7 @@ export async function generateUpdateManifest(
|
|||||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||||
);
|
);
|
||||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||||
|
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
|
||||||
console.log(
|
console.log(
|
||||||
manualNotes
|
manualNotes
|
||||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||||
@@ -733,6 +790,7 @@ export async function generateUpdateManifest(
|
|||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
artifact,
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath,
|
manifestPath,
|
||||||
notes,
|
notes,
|
||||||
|
|||||||
@@ -32,12 +32,23 @@ import {
|
|||||||
resolveReleaseContext,
|
resolveReleaseContext,
|
||||||
resolveRemoteHighWaterVersion,
|
resolveRemoteHighWaterVersion,
|
||||||
runTauriBuild,
|
runTauriBuild,
|
||||||
|
selectFirstInstallArtifact,
|
||||||
selectReleaseArtifact,
|
selectReleaseArtifact,
|
||||||
updateManifestUrl,
|
updateManifestUrl,
|
||||||
} from './build-release.mjs';
|
} from './build-release.mjs';
|
||||||
|
|
||||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||||
const universalTarget = 'universal-apple-darwin';
|
const universalTarget = 'universal-apple-darwin';
|
||||||
|
const packageVersion = JSON.parse(
|
||||||
|
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
||||||
|
).version;
|
||||||
|
|
||||||
|
function createDmgFixture(root, target, version = packageVersion) {
|
||||||
|
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||||
|
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
|
||||||
|
writeFileSync(dmg, 'first installation disk image');
|
||||||
|
return dmg;
|
||||||
|
}
|
||||||
|
|
||||||
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
||||||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
||||||
@@ -254,7 +265,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
|||||||
),
|
),
|
||||||
artifact,
|
artifact,
|
||||||
);
|
);
|
||||||
const manifest = createUpdateManifest(artifact, context);
|
const manifest = createUpdateManifest(artifact, {
|
||||||
|
...context,
|
||||||
|
downloadArtifact: createDmgFixture(
|
||||||
|
path.dirname(artifact),
|
||||||
|
context.target,
|
||||||
|
),
|
||||||
|
});
|
||||||
assert.deepEqual(Object.keys(manifest.platforms), [
|
assert.deepEqual(Object.keys(manifest.platforms), [
|
||||||
'darwin-aarch64',
|
'darwin-aarch64',
|
||||||
]);
|
]);
|
||||||
@@ -272,29 +289,118 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
|||||||
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
|
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
|
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
||||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
|
test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
|
||||||
|
try {
|
||||||
|
const artifact = path.join(root, '陶泥儿.app.tar.gz');
|
||||||
|
writeFileSync(artifact, 'mac package');
|
||||||
|
writeFileSync(`${artifact}.sig`, 'mac signature');
|
||||||
|
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||||
|
const downloadArtifact = createDmgFixture(root, target);
|
||||||
|
const context = {
|
||||||
|
...resolveReleaseContext([`--target=${target}`], {}),
|
||||||
|
bundleRoot: root,
|
||||||
|
};
|
||||||
|
const result = await withStubbedFetch(
|
||||||
|
(url) => {
|
||||||
|
assert.match(url, /\/dev-mac\/latest\.json$/);
|
||||||
|
return jsonResponse({}, 404);
|
||||||
|
},
|
||||||
|
() => generateUpdateManifest(context),
|
||||||
|
);
|
||||||
|
assert.equal(result.artifact, artifact);
|
||||||
|
assert.equal(result.downloadArtifact, downloadArtifact);
|
||||||
|
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
|
||||||
|
assert.equal(result.legacyManifestPath, null);
|
||||||
|
const key = target.startsWith('aarch64')
|
||||||
|
? 'darwin-aarch64'
|
||||||
|
: 'darwin-x86_64';
|
||||||
|
assert.deepEqual(Object.keys(result.manifest.platforms), [key]);
|
||||||
|
assert.deepEqual(Object.keys(result.manifest.downloads), [key]);
|
||||||
|
assert.match(
|
||||||
|
result.manifest.platforms[key].url,
|
||||||
|
/\/dev-mac\/.*\.app\.tar\.gz$/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
decodeURIComponent(
|
||||||
|
new URL(result.manifest.downloads[key].url).pathname,
|
||||||
|
),
|
||||||
|
`/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(readFileSync(result.manifestPath, 'utf8')),
|
||||||
|
result.manifest,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-'));
|
||||||
|
try {
|
||||||
|
const target = 'aarch64-apple-darwin';
|
||||||
|
const options = {
|
||||||
|
target,
|
||||||
|
version: '2.3.4',
|
||||||
|
artifact: path.join(root, '陶泥儿.app.tar.gz'),
|
||||||
|
};
|
||||||
|
const oldVersion = createDmgFixture(root, target, '2.3.3');
|
||||||
|
const wrongArchitecture = createDmgFixture(
|
||||||
|
root,
|
||||||
|
'x86_64-apple-darwin',
|
||||||
|
'2.3.4',
|
||||||
|
);
|
||||||
|
assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
selectFirstInstallArtifact([oldVersion, wrongArchitecture], options),
|
||||||
|
/找到 0 个/u,
|
||||||
|
);
|
||||||
|
const current = createDmgFixture(root, target, '2.3.4');
|
||||||
|
assert.equal(
|
||||||
|
selectFirstInstallArtifact(
|
||||||
|
[oldVersion, wrongArchitecture, current],
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
writeFileSync(current, '');
|
||||||
|
assert.throws(
|
||||||
|
() => selectFirstInstallArtifact([current], options),
|
||||||
|
/不存在或为空/u,
|
||||||
|
);
|
||||||
|
writeFileSync(current, 'valid dmg');
|
||||||
|
const second = path.join(root, '另一包_2.3.4_aarch64.dmg');
|
||||||
|
writeFileSync(second, 'ambiguous dmg');
|
||||||
|
assert.throws(
|
||||||
|
() => selectFirstInstallArtifact([current, second], options),
|
||||||
|
/找到 2 个/u,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => {
|
||||||
|
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-'));
|
||||||
try {
|
try {
|
||||||
const artifact = path.join(root, '陶泥儿.app.tar.gz');
|
const artifact = path.join(root, '陶泥儿.app.tar.gz');
|
||||||
writeFileSync(artifact, 'mac package');
|
writeFileSync(artifact, 'updater archive');
|
||||||
writeFileSync(`${artifact}.sig`, 'mac signature');
|
writeFileSync(`${artifact}.sig`, 'signature');
|
||||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
|
||||||
const context = {
|
const context = {
|
||||||
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
|
...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}),
|
||||||
bundleRoot: root,
|
bundleRoot: root,
|
||||||
};
|
};
|
||||||
const result = await withStubbedFetch(
|
await assert.rejects(
|
||||||
(url) => {
|
|
||||||
assert.match(url, /\/dev-mac\/latest\.json$/);
|
|
||||||
return jsonResponse({}, 404);
|
|
||||||
},
|
|
||||||
() => generateUpdateManifest(context),
|
() => generateUpdateManifest(context),
|
||||||
|
/首装 DMG 必须唯一匹配/u,
|
||||||
);
|
);
|
||||||
assert.equal(result.artifact, artifact);
|
assert.throws(() => readFileSync(path.join(root, 'latest.json')), {
|
||||||
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
|
code: 'ENOENT',
|
||||||
assert.equal(result.legacyManifestPath, null);
|
});
|
||||||
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
|
|
||||||
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -344,7 +450,11 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
|||||||
context,
|
context,
|
||||||
{
|
{
|
||||||
spawn: (_binary, command) => {
|
spawn: (_binary, command) => {
|
||||||
assert.ok(command.includes('--features=cocos-editor-execute'));
|
assert.ok(
|
||||||
|
command.includes(
|
||||||
|
'--features=cocos-editor-execute,unity-editor-execute',
|
||||||
|
),
|
||||||
|
);
|
||||||
assert.ok(command.includes('user-config.json'));
|
assert.ok(command.includes('user-config.json'));
|
||||||
const configIndex = command.lastIndexOf('--config');
|
const configIndex = command.lastIndexOf('--config');
|
||||||
const config = JSON.parse(
|
const config = JSON.parse(
|
||||||
@@ -389,6 +499,9 @@ test('channel manifest carries version, platform keys and signature', () => {
|
|||||||
assert.equal(manifest.notes, '修复与改进');
|
assert.equal(manifest.notes, '修复与改进');
|
||||||
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
|
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
|
||||||
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
|
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
|
||||||
|
assert.deepEqual(manifest.downloads, {
|
||||||
|
'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url },
|
||||||
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
manifest.platforms['windows-x86_64'].signature,
|
manifest.platforms['windows-x86_64'].signature,
|
||||||
'signature-content',
|
'signature-content',
|
||||||
@@ -569,18 +682,18 @@ test('recent commit fallback marks that entries may repeat the previous release'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
test('release entry forwards the built artifacts and dry-run mode to the uploader', () => {
|
||||||
const source = readFileSync(
|
const source = readFileSync(
|
||||||
new URL('./release-upload.mjs', import.meta.url),
|
new URL('./release-upload.mjs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.match(
|
||||||
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
|
source,
|
||||||
4,
|
/const release = await buildRelease\(process\.argv\.slice\(2\)\)/u,
|
||||||
);
|
);
|
||||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
assert.match(source, /uploadReleaseArtifacts\(release, \{/u);
|
||||||
assert.match(source, /agc\/latest\.json/u);
|
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||||
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
|
assert.ok(source.includes('\n dryRun,\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('release notes list client commits with short sha and bound their size', () => {
|
test('release notes list client commits with short sha and bound their size', () => {
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ export function withDefaultCargoFeatures(argv, features) {
|
|||||||
|
|
||||||
export function defaultEditorFeatures(target) {
|
export function defaultEditorFeatures(target) {
|
||||||
return target === 'win32' || target.includes('windows')
|
return target === 'win32' || target.includes('windows')
|
||||||
? ['cocos-editor-execute']
|
? ['cocos-editor-execute', 'unity-editor-execute']
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test('Windows release includes the same editor feature as development', () => {
|
|||||||
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
|
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
|
||||||
[
|
[
|
||||||
'build',
|
'build',
|
||||||
'--features=cocos-editor-execute',
|
'--features=cocos-editor-execute,unity-editor-execute',
|
||||||
'--target',
|
'--target',
|
||||||
'x86_64-pc-windows-msvc',
|
'x86_64-pc-windows-msvc',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ const allowedUncalledTauriCommands = [
|
|||||||
'reload_agc_plugin',
|
'reload_agc_plugin',
|
||||||
'call_agc_plugin',
|
'call_agc_plugin',
|
||||||
'read_agc_plugin_panel',
|
'read_agc_plugin_panel',
|
||||||
'set_agc_plugin_project_path',
|
|
||||||
'set_agc_plugin_enabled',
|
'set_agc_plugin_enabled',
|
||||||
];
|
];
|
||||||
const sourceExtensions = new Set([
|
const sourceExtensions = new Set([
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ function deterministicArtManifest(
|
|||||||
{
|
{
|
||||||
id: 'garden-guardians-spritesheet',
|
id: 'garden-guardians-spritesheet',
|
||||||
path: 'assets/art-spritesheet.png',
|
path: 'assets/art-spritesheet.png',
|
||||||
kind: 'art-spritesheet',
|
kind: 'icon-spritesheet',
|
||||||
usage: ['defenders', 'enemies', 'battlefield-ui'],
|
usage: ['defenders', 'enemies', 'battlefield-ui'],
|
||||||
source: 'canvas',
|
source: 'canvas',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -710,7 +710,7 @@ function canvasAssetCall(agentId) {
|
|||||||
outputPath: 'assets/ui-prototype.png',
|
outputPath: 'assets/ui-prototype.png',
|
||||||
aspectRatio: '16:9',
|
aspectRatio: '16:9',
|
||||||
imageSize: '2K',
|
imageSize: '2K',
|
||||||
assetKind: 'ui-prototype',
|
assetKind: 'ui-design',
|
||||||
assetLabel: '游戏横屏界面原型图',
|
assetLabel: '游戏横屏界面原型图',
|
||||||
replaceExisting: false,
|
replaceExisting: false,
|
||||||
});
|
});
|
||||||
@@ -721,7 +721,7 @@ function canvasAssetCall(agentId) {
|
|||||||
outputPath: 'assets/art-spritesheet.png',
|
outputPath: 'assets/art-spritesheet.png',
|
||||||
aspectRatio: '1:1',
|
aspectRatio: '1:1',
|
||||||
imageSize: '1K',
|
imageSize: '1K',
|
||||||
assetKind: 'art-spritesheet',
|
assetKind: 'icon-spritesheet',
|
||||||
assetLabel: '游戏首版核心美术素材',
|
assetLabel: '游戏首版核心美术素材',
|
||||||
replaceExisting: false,
|
replaceExisting: false,
|
||||||
sliceMode: 'connected-components',
|
sliceMode: 'connected-components',
|
||||||
@@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) {
|
|||||||
},
|
},
|
||||||
'design-foundation': {
|
'design-foundation': {
|
||||||
path: 'assets/ui-prototype.png',
|
path: 'assets/ui-prototype.png',
|
||||||
kind: 'ui-prototype',
|
kind: 'ui-design',
|
||||||
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
|
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
|
||||||
},
|
},
|
||||||
'art-asset-plan': {
|
'art-asset-plan': {
|
||||||
path: 'assets/art-spritesheet.png',
|
path: 'assets/art-spritesheet.png',
|
||||||
kind: 'art-spritesheet',
|
kind: 'icon-spritesheet',
|
||||||
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
|
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
|
||||||
},
|
},
|
||||||
}[agentId];
|
}[agentId];
|
||||||
@@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
|||||||
asset: {
|
asset: {
|
||||||
assetId: `asset-${sliceImageId}`,
|
assetId: `asset-${sliceImageId}`,
|
||||||
assetObjectId: sliceAssetObjectId,
|
assetObjectId: sliceAssetObjectId,
|
||||||
assetKind: 'art-spritesheet-slice',
|
assetKind: 'icon',
|
||||||
projectId,
|
projectId,
|
||||||
taskId,
|
taskId,
|
||||||
},
|
},
|
||||||
@@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
|||||||
spritesheetAsset: {
|
spritesheetAsset: {
|
||||||
assetId: `asset-${imageId}`,
|
assetId: `asset-${imageId}`,
|
||||||
assetObjectId,
|
assetObjectId,
|
||||||
assetKind: 'art-spritesheet',
|
assetKind: 'icon-spritesheet',
|
||||||
projectId,
|
projectId,
|
||||||
taskId,
|
taskId,
|
||||||
},
|
},
|
||||||
@@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
|||||||
const assetObjectId = `asset-object-${imageId}`;
|
const assetObjectId = `asset-object-${imageId}`;
|
||||||
const resourceId = `resource-${imageId}`;
|
const resourceId = `resource-${imageId}`;
|
||||||
const assetKind =
|
const assetKind =
|
||||||
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
|
typeof body?.assetKind === 'string' ? body.assetKind : 'image';
|
||||||
images.set(imageId, {
|
images.set(imageId, {
|
||||||
...image,
|
...image,
|
||||||
objectKey,
|
objectKey,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||||
@@ -25,3 +28,89 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
|||||||
}
|
}
|
||||||
return parts.map(quoteArgument).join(' ');
|
return parts.map(quoteArgument).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createReleaseUploadPlan(
|
||||||
|
{
|
||||||
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
|
channel,
|
||||||
|
manifest,
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
},
|
||||||
|
bucket,
|
||||||
|
) {
|
||||||
|
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
|
||||||
|
throw new Error('发布结果缺少更新包、首装包或清单');
|
||||||
|
}
|
||||||
|
const prefix = `oss://${bucket}/agc/${channel}`;
|
||||||
|
const artifacts = [
|
||||||
|
...new Set(
|
||||||
|
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
|
||||||
|
path.resolve(file),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const plan = artifacts.map((source) => ({
|
||||||
|
source,
|
||||||
|
destination: `${prefix}/${manifest.version}/${path.basename(source)}`,
|
||||||
|
}));
|
||||||
|
plan.push({ source: manifestPath, destination: `${prefix}/latest.json` });
|
||||||
|
if (legacyManifestPath) {
|
||||||
|
plan.push({
|
||||||
|
source: legacyManifestPath,
|
||||||
|
destination: `oss://${bucket}/agc/latest.json`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadReleaseArtifacts(
|
||||||
|
release,
|
||||||
|
{
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
binary = 'ossutil',
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
dryRun = false,
|
||||||
|
spawn = spawnSync,
|
||||||
|
log = console.log,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||||
|
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||||
|
}
|
||||||
|
const plan = createReleaseUploadPlan(release, bucket);
|
||||||
|
for (const { source, destination } of plan) {
|
||||||
|
// 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。
|
||||||
|
const args = ['cp', '--force', source, destination];
|
||||||
|
if (dryRun) {
|
||||||
|
log(
|
||||||
|
`[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const credentials = accessKeyId
|
||||||
|
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||||
|
: [];
|
||||||
|
const result = spawn(
|
||||||
|
binary,
|
||||||
|
[...args, '--endpoint', endpoint, ...credentials],
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error)
|
||||||
|
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(`[ai-game-creator-shell] 已上传 ${destination}`);
|
||||||
|
}
|
||||||
|
if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|
||||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
import {
|
||||||
|
createReleaseUploadPlan,
|
||||||
|
formatOssutilCommand,
|
||||||
|
readReleaseDryRun,
|
||||||
|
uploadReleaseArtifacts,
|
||||||
|
} from './release-oss.mjs';
|
||||||
|
|
||||||
test('dry run only accepts explicit truthy values', () => {
|
test('dry run only accepts explicit truthy values', () => {
|
||||||
assert.equal(readReleaseDryRun({}), false);
|
assert.equal(readReleaseDryRun({}), false);
|
||||||
@@ -33,12 +40,158 @@ test('printed upload command keeps arguments and hides credentials', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uploader gates every ossutil call behind the dry run switch', () => {
|
function withReleaseFixture(channel, architecture, run) {
|
||||||
const source = readFileSync(
|
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
|
||||||
new URL('./release-upload.mjs', import.meta.url),
|
try {
|
||||||
'utf8',
|
const artifact = path.join(
|
||||||
);
|
root,
|
||||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
channel === 'dev-win'
|
||||||
assert.match(source, /if \(dryRun\) \{/u);
|
? '陶泥儿_1.2.3_x64-setup.exe'
|
||||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
: '陶泥儿.app.tar.gz',
|
||||||
|
);
|
||||||
|
const downloadArtifact =
|
||||||
|
channel === 'dev-win'
|
||||||
|
? artifact
|
||||||
|
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
|
||||||
|
const manifestPath = path.join(root, 'latest.json');
|
||||||
|
const legacyManifestPath =
|
||||||
|
channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null;
|
||||||
|
for (const file of [
|
||||||
|
artifact,
|
||||||
|
`${artifact}.sig`,
|
||||||
|
downloadArtifact,
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
].filter(Boolean)) {
|
||||||
|
writeFileSync(file, 'fixture');
|
||||||
|
}
|
||||||
|
return run({
|
||||||
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
|
channel,
|
||||||
|
manifest: { version: '1.2.3' },
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadOptions = {
|
||||||
|
bucket: 'agc-dev',
|
||||||
|
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||||
|
log: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const architecture of ['aarch64', 'x64']) {
|
||||||
|
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
|
||||||
|
withReleaseFixture('dev-mac', architecture, (release) => {
|
||||||
|
const calls = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (binary, args, options) => {
|
||||||
|
assert.equal(binary, 'ossutil');
|
||||||
|
assert.equal(options.shell, false);
|
||||||
|
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||||
|
calls.push({ source: args[2], destination: args[3] });
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map(({ source }) => source),
|
||||||
|
[
|
||||||
|
release.artifact,
|
||||||
|
`${release.artifact}.sig`,
|
||||||
|
release.downloadArtifact,
|
||||||
|
release.manifestPath,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calls[2].destination,
|
||||||
|
`oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calls[3].destination,
|
||||||
|
'oss://agc-dev/agc/dev-mac/latest.json',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
|
||||||
|
withReleaseFixture('dev-win', 'x64', (release) => {
|
||||||
|
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||||
|
assert.deepEqual(
|
||||||
|
plan.map(({ source }) => source),
|
||||||
|
[
|
||||||
|
release.artifact,
|
||||||
|
`${release.artifact}.sig`,
|
||||||
|
release.manifestPath,
|
||||||
|
release.legacyManifestPath,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
|
||||||
|
const calls = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (_binary, args) => {
|
||||||
|
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||||
|
calls.push(args[3]);
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
calls,
|
||||||
|
plan.map(({ destination }) => destination),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failedArtifactIndex of [0, 1, 2]) {
|
||||||
|
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
|
||||||
|
withReleaseFixture('dev-mac', 'aarch64', (release) => {
|
||||||
|
const destinations = [];
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (_binary, args) => {
|
||||||
|
destinations.push(args[3]);
|
||||||
|
return {
|
||||||
|
status: destinations.length - 1 === failedArtifactIndex ? 1 : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/OSS 上传失败/u,
|
||||||
|
);
|
||||||
|
assert.equal(destinations.length, failedArtifactIndex + 1);
|
||||||
|
assert.ok(
|
||||||
|
destinations.every(
|
||||||
|
(destination) => !destination.endsWith('/latest.json'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
|
||||||
|
withReleaseFixture('dev-mac', 'aarch64', (release) => {
|
||||||
|
const output = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
dryRun: true,
|
||||||
|
accessKeyId: 'fixture-id',
|
||||||
|
accessKeySecret: 'fixture-secret',
|
||||||
|
spawn: () => assert.fail('dry run must never execute ossutil'),
|
||||||
|
log: (line) => output.push(line),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
output.filter((line) => line.startsWith('[dry-run]')).length,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
assert.match(output.join('\n'), /\.dmg/u);
|
||||||
|
assert.match(output.at(-1), /未写入任何 OSS 对象/u);
|
||||||
|
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
|
||||||
|
|
||||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||||
const endpoint =
|
const endpoint =
|
||||||
@@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun();
|
|||||||
|
|
||||||
const { buildRelease } = await import('./build-release.mjs');
|
const { buildRelease } = await import('./build-release.mjs');
|
||||||
|
|
||||||
function runOssutil(args) {
|
const release = await buildRelease(process.argv.slice(2));
|
||||||
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
|
uploadReleaseArtifacts(release, {
|
||||||
const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim();
|
bucket,
|
||||||
const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET;
|
endpoint,
|
||||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||||
}
|
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||||
if (dryRun) {
|
dryRun,
|
||||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
});
|
||||||
console.log(
|
|
||||||
`[dry-run] ${formatOssutilCommand({
|
|
||||||
binary,
|
|
||||||
args,
|
|
||||||
endpoint,
|
|
||||||
credentials: Boolean(accessKeyId),
|
|
||||||
})}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const credentialArgs = accessKeyId
|
|
||||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
|
||||||
: [];
|
|
||||||
const result = spawnSync(
|
|
||||||
binary,
|
|
||||||
[...args, '--endpoint', endpoint, ...credentialArgs],
|
|
||||||
{
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
|
||||||
}
|
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
|
||||||
await buildRelease(process.argv.slice(2));
|
|
||||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
|
||||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
|
||||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
|
||||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
`${artifact}.sig`,
|
|
||||||
`oss://${bucket}/${artifactKey}.sig`,
|
|
||||||
]);
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
manifestPath,
|
|
||||||
`oss://${bucket}/agc/${channel}/latest.json`,
|
|
||||||
]);
|
|
||||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
|
||||||
console.log(
|
|
||||||
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
|
|
||||||
);
|
|
||||||
if (legacyManifestPath) {
|
|
||||||
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
legacyManifestPath,
|
|
||||||
`oss://${bucket}/agc/latest.json`,
|
|
||||||
]);
|
|
||||||
console.log(
|
|
||||||
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dryRun) {
|
|
||||||
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { withDefaultCargoFeatures } from './cargo-features.mjs';
|
import {
|
||||||
|
defaultEditorFeatures,
|
||||||
|
withDefaultCargoFeatures,
|
||||||
|
} from './cargo-features.mjs';
|
||||||
import {
|
import {
|
||||||
readAgcDevEndpoint,
|
readAgcDevEndpoint,
|
||||||
resolveAgcDevEndpoint,
|
resolveAgcDevEndpoint,
|
||||||
@@ -47,9 +50,8 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// `agc_cocos_execute` 与 Cocos 编辑器适配器只在 `cocos-editor-execute` feature 下
|
// 开发和发行构建使用同一平台编辑器 feature 集合。
|
||||||
// 注册。开发构建默认在 Windows 打开它,否则 Agent 的工具清单里根本没有该工具,
|
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
|
||||||
// 只能退化成改写脚本。可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
|
|
||||||
function readDevCargoFeatures(env = process.env) {
|
function readDevCargoFeatures(env = process.env) {
|
||||||
const override = env.AGC_DEV_CARGO_FEATURES;
|
const override = env.AGC_DEV_CARGO_FEATURES;
|
||||||
if (override !== undefined) {
|
if (override !== undefined) {
|
||||||
@@ -58,7 +60,7 @@ function readDevCargoFeatures(env = process.env) {
|
|||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
return process.platform === 'win32' ? ['cocos-editor-execute'] : [];
|
return defaultEditorFeatures(process.platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
||||||
|
|||||||
+13
@@ -1793,6 +1793,7 @@ dependencies = [
|
|||||||
"ttf-parser",
|
"ttf-parser",
|
||||||
"typed_floats",
|
"typed_floats",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
|
"unity-editor-bridge",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3931,6 +3932,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5016,6 +5018,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"ts-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6407,6 +6410,16 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unity-editor-bridge"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"editor-adapter-api",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unsafe-libyaml"
|
name = "unsafe-libyaml"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ template-library-fixtures = []
|
|||||||
cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
||||||
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
||||||
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
||||||
|
unity-editor-execute = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -27,6 +28,7 @@ nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
|||||||
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||||
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
|
cocos-editor-bridge = { path = "../../../plugins/agc-cocos-editor/native/cocos-editor-bridge", default-features = false }
|
||||||
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
|
editor-adapter-api = { path = "../../../server-rs/crates/editor-adapter-api" }
|
||||||
|
unity-editor-bridge = { path = "../../../plugins/agc-unity-editor/native/unity-editor-bridge" }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
chromiumoxide = "0.9.1"
|
chromiumoxide = "0.9.1"
|
||||||
@@ -56,7 +58,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
|||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||||
tauri = { version = "2.11.2", features = [] }
|
tauri = { version = "2.11.2", features = [] }
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.7.1"
|
||||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ fn main() {
|
|||||||
);
|
);
|
||||||
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
|
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
|
||||||
stage_bundled_codex_cli(&manifest_dir);
|
stage_bundled_codex_cli(&manifest_dir);
|
||||||
|
prepare_unity_editor_helper(&manifest_dir);
|
||||||
stage_plugin_workspace(&manifest_dir);
|
stage_plugin_workspace(&manifest_dir);
|
||||||
stage_cocos_editor_payload(&manifest_dir);
|
stage_cocos_editor_payload(&manifest_dir);
|
||||||
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
|
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
|
||||||
@@ -266,6 +267,107 @@ fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
|
|||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
|
fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
|
||||||
|
|
||||||
|
/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。
|
||||||
|
fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) {
|
||||||
|
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE");
|
||||||
|
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||||
|
if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()
|
||||||
|
|| target != "x86_64-pc-windows-msvc"
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet");
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
collect_unity_helper_sources(&root, &mut sources);
|
||||||
|
sources.sort();
|
||||||
|
let mut fingerprint = Sha256::new();
|
||||||
|
for source in &sources {
|
||||||
|
println!("cargo:rerun-if-changed={}", source.display());
|
||||||
|
fingerprint.update(
|
||||||
|
source
|
||||||
|
.strip_prefix(&root)
|
||||||
|
.expect("helper source")
|
||||||
|
.to_string_lossy()
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
fingerprint.update([0]);
|
||||||
|
fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败"));
|
||||||
|
}
|
||||||
|
let fingerprint = format!("{:x}", fingerprint.finalize());
|
||||||
|
let publish = root.join("publish/win-x64");
|
||||||
|
let executable = publish.join("Agc.Unity.Attach.exe");
|
||||||
|
let stamp = publish.join(".agc-source.sha256");
|
||||||
|
println!("cargo:rerun-if-changed={}", executable.display());
|
||||||
|
if unity_helper_publish_complete(&publish)
|
||||||
|
&& fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cfg!(windows),
|
||||||
|
"构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链"
|
||||||
|
);
|
||||||
|
let status = std::process::Command::new("powershell.exe")
|
||||||
|
.args([
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
])
|
||||||
|
.arg(root.join("build.ps1"))
|
||||||
|
.current_dir(&root)
|
||||||
|
.status()
|
||||||
|
.expect("无法启动 Unity helper 构建脚本");
|
||||||
|
assert!(
|
||||||
|
status.success() && unity_helper_publish_complete(&publish),
|
||||||
|
"Unity helper 构建失败或缺少运行文件/许可"
|
||||||
|
);
|
||||||
|
fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unity_helper_publish_complete(publish: &std::path::Path) -> bool {
|
||||||
|
[
|
||||||
|
"Agc.Unity.Attach.exe",
|
||||||
|
"NOTICE",
|
||||||
|
"THIRD-PARTY-NOTICES.txt",
|
||||||
|
"licenses/DotCraft-Apache-2.0.txt",
|
||||||
|
"licenses/Roslyn-MIT.txt",
|
||||||
|
"licenses/upstream.json",
|
||||||
|
"licenses/dotnet-LICENSE.TXT",
|
||||||
|
"licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
|
||||||
|
"licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
|
||||||
|
"licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.all(|name| {
|
||||||
|
fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| {
|
||||||
|
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec<PathBuf>) {
|
||||||
|
for entry in fs::read_dir(root)
|
||||||
|
.expect("Unity helper 源码目录缺失")
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败");
|
||||||
|
assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接");
|
||||||
|
let name = entry.file_name();
|
||||||
|
if kind.is_dir() {
|
||||||
|
if !matches!(
|
||||||
|
name.to_str(),
|
||||||
|
Some("bin" | "obj" | "publish" | "native-build")
|
||||||
|
) {
|
||||||
|
collect_unity_helper_sources(&entry.path(), sources);
|
||||||
|
}
|
||||||
|
} else if kind.is_file() {
|
||||||
|
sources.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
|
/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
|
||||||
///
|
///
|
||||||
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
|
/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
|
||||||
@@ -313,9 +415,15 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
|
|||||||
for relative in [
|
for relative in [
|
||||||
std::path::PathBuf::from("src"),
|
std::path::PathBuf::from("src"),
|
||||||
std::path::PathBuf::from("panels"),
|
std::path::PathBuf::from("panels"),
|
||||||
|
std::path::PathBuf::from("skills"),
|
||||||
std::path::PathBuf::from("native/payload"),
|
std::path::PathBuf::from("native/payload"),
|
||||||
|
std::path::PathBuf::from("dotnet/publish/win-x64"),
|
||||||
] {
|
] {
|
||||||
if relative == std::path::Path::new("native/payload") && !target.contains("windows") {
|
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|
||||||
|
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
|
||||||
|
&& (target != "x86_64-pc-windows-msvc"
|
||||||
|
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
|
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
"agents/openai.yaml",
|
"agents/openai.yaml",
|
||||||
"references/platform-art-contract.md"
|
"references/platform-art-contract.md"
|
||||||
],
|
],
|
||||||
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
|
"sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "agc-web-game-development",
|
"name": "agc-web-game-development",
|
||||||
|
|||||||
+2
-2
@@ -19,13 +19,13 @@ image, UI design image, or publication material; use `agc_edit_image` for an
|
|||||||
edit of an existing registered image; use `taonier_prepare_game_art` only for
|
edit of an existing registered image; use `taonier_prepare_game_art` only for
|
||||||
the complete game-art package and its canonical slices.
|
the complete game-art package and its canonical slices.
|
||||||
|
|
||||||
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
|
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"`
|
||||||
generate the subject on a solid-colour background and automatically matte it
|
generate the subject on a solid-colour background and automatically matte it
|
||||||
away afterwards, producing transparent-background results; write the prompt
|
away afterwards, producing transparent-background results; write the prompt
|
||||||
for the subject only, never for a scene. `kind="image"` keeps the rendered
|
for the subject only, never for a scene. `kind="image"` keeps the rendered
|
||||||
frame without extra processing.
|
frame without extra processing.
|
||||||
|
|
||||||
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
|
When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is
|
||||||
required and has no default, so decide it explicitly:
|
required and has no default, so decide it explicitly:
|
||||||
|
|
||||||
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
|
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
|
||||||
|
|||||||
@@ -1198,7 +1198,7 @@ mod tests {
|
|||||||
"tool": "agc_generate_image",
|
"tool": "agc_generate_image",
|
||||||
"arguments": {
|
"arguments": {
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"kind": "art-spritesheet",
|
"kind": "icon-spritesheet",
|
||||||
"sliceMode": "connected-components",
|
"sliceMode": "connected-components",
|
||||||
"sliceCount": 8,
|
"sliceCount": 8,
|
||||||
"screenColor": "#CFEFFF"
|
"screenColor": "#CFEFFF"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||||
|
use crate::ui_editor::persistence::{
|
||||||
|
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||||
|
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
|||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
||||||
wire.as_array()
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
|
let mut prompt = wire
|
||||||
|
.as_array()
|
||||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||||
.map(|parts| {
|
.and_then(|parts| {
|
||||||
parts
|
parts
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
.map(|part| {
|
||||||
.collect::<String>()
|
part.get("text")
|
||||||
})
|
.and_then(Value::as_str)
|
||||||
.and_then(|prompt| {
|
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||||
if prompt.trim().is_empty() {
|
})
|
||||||
Err("DirectProject user item 不能转换为空 prompt".to_string())
|
.collect::<Result<String, String>>()
|
||||||
} else {
|
})?;
|
||||||
Ok(prompt)
|
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||||
|
prompt.push('\n');
|
||||||
|
prompt.push_str(&code_context);
|
||||||
|
}
|
||||||
|
if prompt.trim().is_empty() {
|
||||||
|
return Err("DirectProject user item 不能转换为空 prompt".to_string());
|
||||||
|
}
|
||||||
|
Ok(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的
|
||||||
|
/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。
|
||||||
|
///
|
||||||
|
/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item`
|
||||||
|
/// 的纯投影,不得在这里产生项目写副作用。
|
||||||
|
fn render_ui_design_code_context(
|
||||||
|
root: &Path,
|
||||||
|
message: &DirectCodexUserMessageItem,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let referenced_ids = message
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| match part {
|
||||||
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
|
Some(resource_id.trim())
|
||||||
}
|
}
|
||||||
|
_ => None,
|
||||||
})
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if referenced_ids.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let manifest = read_manifest_for_project(root)?;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for resource_id in referenced_ids {
|
||||||
|
let is_ui_design_doc = manifest.assets.iter().any(|asset| {
|
||||||
|
asset.id == resource_id
|
||||||
|
&& asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||||
|
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||||
|
});
|
||||||
|
if !is_ui_design_doc {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
match generate_ui_design_code_at(GenerateUiDesignCodeInput {
|
||||||
|
project_path: root.to_string_lossy().into_owned(),
|
||||||
|
expected_project_id: manifest.project_id.clone(),
|
||||||
|
asset_id: resource_id.to_string(),
|
||||||
|
}) {
|
||||||
|
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
|
||||||
|
Err(error) => format!("生成代码遇到错误{error}"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(lines.join("\n")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::direct_codex_user_item_to_response_item;
|
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
|
||||||
|
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use shared_contracts::game_creation_app::{
|
||||||
|
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||||
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project";
|
||||||
|
|
||||||
|
fn prompt_context_project() -> tempfile::TempDir {
|
||||||
|
let directory = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(
|
||||||
|
directory.path(),
|
||||||
|
PROMPT_CONTEXT_PROJECT_ID,
|
||||||
|
"Direct prompt 上下文测试",
|
||||||
|
)
|
||||||
|
.expect("init project");
|
||||||
|
directory
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_fixture_asset(
|
||||||
|
root: &Path,
|
||||||
|
relative_path: &str,
|
||||||
|
kind: GameCreationAppAssetKind,
|
||||||
|
media_type: &str,
|
||||||
|
) -> String {
|
||||||
|
let absolute_path = crate::resolve_local_project_path(root, relative_path)
|
||||||
|
.expect("resolve fixture asset path");
|
||||||
|
std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent"))
|
||||||
|
.expect("create fixture asset parent");
|
||||||
|
std::fs::write(&absolute_path, b"{}").expect("write fixture asset file");
|
||||||
|
crate::assets::register_local_asset_at(
|
||||||
|
root,
|
||||||
|
relative_path,
|
||||||
|
kind,
|
||||||
|
media_type,
|
||||||
|
"prompt-context-test",
|
||||||
|
GameCreationAppAssetSource {
|
||||||
|
kind: GameCreationAppAssetSourceKind::Generated,
|
||||||
|
canvas_project_id: None,
|
||||||
|
resource_id: Some("prompt-context".to_string()),
|
||||||
|
asset_object_id: None,
|
||||||
|
task_id: None,
|
||||||
|
prompt: None,
|
||||||
|
model: None,
|
||||||
|
generation_route: None,
|
||||||
|
generation_kind: None,
|
||||||
|
reference_resource_ids: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("register fixture asset")
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) {
|
||||||
|
let directory = prompt_context_project();
|
||||||
|
let asset_id = register_fixture_asset(
|
||||||
|
directory.path(),
|
||||||
|
"ui/design.json",
|
||||||
|
GameCreationAppAssetKind::UiDesignDoc,
|
||||||
|
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
|
);
|
||||||
|
if initialize_state {
|
||||||
|
crate::ui_editor::persistence::initialize_ui_design_state_at(
|
||||||
|
directory.path(),
|
||||||
|
PROMPT_CONTEXT_PROJECT_ID,
|
||||||
|
&asset_id,
|
||||||
|
)
|
||||||
|
.expect("initialize UI design state");
|
||||||
|
}
|
||||||
|
(directory, asset_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "turn-1:user",
|
||||||
|
"content": [{"type": "agc_resource_reference", "resourceId": asset_id}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||||
let item = json!({
|
let item = json!({
|
||||||
@@ -174,4 +314,81 @@ mod tests {
|
|||||||
.expect_err("history item without type must fail");
|
.expect_err("history item without type must fail");
|
||||||
assert!(error.contains("缺少 type"), "{error}");
|
assert!(error.contains("缺少 type"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(
|
||||||
|
prompt.contains(&format!(
|
||||||
|
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||||
|
)),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_projection_never_writes_generated_ui_design_code() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||||
|
let item = user_item_with_resource_reference(&asset_id);
|
||||||
|
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||||
|
let generated_root =
|
||||||
|
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||||
|
let generated_files = std::fs::read_dir(generated_root)
|
||||||
|
.expect("read ui directory")
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("generated-")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
generated_files, 0,
|
||||||
|
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||||
|
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
|
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||||
|
let project = prompt_context_project();
|
||||||
|
let asset_id = register_fixture_asset(
|
||||||
|
project.path(),
|
||||||
|
"assets/hero.png",
|
||||||
|
GameCreationAppAssetKind::Character,
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
let item: super::DirectCodexUserItem =
|
||||||
|
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||||
|
.expect("canonical user item");
|
||||||
|
let prompt =
|
||||||
|
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||||
|
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||||
|
"{prompt}"
|
||||||
|
);
|
||||||
|
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user