合并 origin/master 到 feat/tribo3d-integeration:ts-rs 一律常开、Cargo 清单去重
- server-rs/Cargo.toml:删除与 workspace git 固定重复的 registry `ts-rs = "12.0.1"`,保留固定 commit 并写明「ts-rs 一律常开、绑定命令固定为 cargo test -p shared-contracts export_bindings」 - server-rs/crates/shared-contracts/Cargo.toml:删除 master 侧新增的 `ts-bindings` feature,`ts-rs` 保持常开依赖 - server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs:去掉 `cfg_attr(feature = "ts-bindings", …)` 门控,derive 与字段级 `ts(...)` 无条件展开 - apps/ai-game-creator-shell/src-tauri/Cargo.toml:shared-contracts 依赖去掉 `features = ["ts-bindings"]`,继续保留本分支的 ts-rs git 固定 - apps/ai-game-creator-shell/src-tauri/Cargo.lock:按上述依赖变更同步(shared-contracts 指向 git 源 ts-rs) - docs:同步 5 处绑定生成命令(去掉 `--features ts-bindings`),并在 decision-log 记本次合并的决策、代价与验证 - 其余暂存改动为 origin/master 带入的内容,冲突按两侧目的逐一合并 验证:cargo metadata --locked(server-rs 与 AGC 两个 workspace)、cargo test -p shared-contracts(119 + 5 + 5 + 2 + 2 全绿且无告警)、npm run contracts:model3d:generate 后 packages/shared 零 diff、cargo check -p spacetime-module --target wasm32-unknown-unknown、npm run check:encoding、npm run check:rustfmt、npm run check:spacetime-schema、git diff --check、暂存集 eslint / prettier 全过
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,10 @@ import {
|
||||
cleanupHistoryCandidates,
|
||||
collectDirectFileEntries,
|
||||
createUploadBandwidthLimiter,
|
||||
describeBackupSpaceRequirement,
|
||||
discoverDeferredArchiveUploads,
|
||||
discoverHistoryPlan,
|
||||
discoverMinimalPlan,
|
||||
restoreDirectFilesBackup,
|
||||
restoreDirectFilesLatest,
|
||||
resumeUploadedHistoryBatch,
|
||||
@@ -64,6 +66,8 @@ async function main() {
|
||||
assertDeferredArchiveDiscoveryIsBoundedAndDeterministic();
|
||||
assertCanonicalQueryAndAuthorizationIncludeMultipartParameters();
|
||||
assertInsufficientSpaceStopsBeforeServiceChanges();
|
||||
assertCheckSpaceOnlyUsesFormatSpecificRequirement();
|
||||
assertMinimalPlanKeepsOnlyRetainedSnapshotAndTrailingCommitlog();
|
||||
assertStopFailureRetainsRecoveryMarker();
|
||||
assertArchiveFailureStillRestoresDependentServices();
|
||||
await assertMultipartUploadRetriesAndVerifiesRemoteLength();
|
||||
@@ -1198,7 +1202,7 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() {
|
||||
'999999999999999999',
|
||||
]);
|
||||
|
||||
assertStatus(result, 1, '空间不足时必须失败。');
|
||||
assertStatus(result, 3, '空间不足必须用独立退出码 3 失败。');
|
||||
assertIncludes(
|
||||
result.stdout,
|
||||
'备份空间预检',
|
||||
@@ -1213,6 +1217,69 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() {
|
||||
assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。');
|
||||
}
|
||||
|
||||
function assertCheckSpaceOnlyUsesFormatSpecificRequirement() {
|
||||
const fixture = createFixture('check-space-only');
|
||||
|
||||
const filesOk = runBackup(
|
||||
fixture,
|
||||
['--check-space-only', '--storage-format', 'files'],
|
||||
{ GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1M' },
|
||||
);
|
||||
assertStatus(filesOk, 0, 'files 模式空间预检应在阈值满足时通过。');
|
||||
assertIncludes(
|
||||
filesOk.stdout,
|
||||
'备份空间预检(files)',
|
||||
'files 预检必须打印 files 口径的预检结果。',
|
||||
);
|
||||
assertIncludes(
|
||||
filesOk.stdout,
|
||||
'空间预检通过(check-space-only)',
|
||||
'check-space-only 通过时必须给出显式成功标记。',
|
||||
);
|
||||
assertFileMissing(fixture.systemctlLog, '空间预检不得调用 systemctl。');
|
||||
assertFileMissing(fixture.tarLog, '空间预检不得调用 tar。');
|
||||
|
||||
const filesInsufficient = runBackup(
|
||||
fixture,
|
||||
['--check-space-only', '--storage-format', 'files'],
|
||||
{ GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1000T' },
|
||||
);
|
||||
assertStatus(filesInsufficient, 3, 'files 空间不足同样使用退出码 3。');
|
||||
|
||||
const archiveInsufficient = runBackup(
|
||||
fixture,
|
||||
['--check-space-only', '--storage-format', 'archive'],
|
||||
{ GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES: '1000T' },
|
||||
);
|
||||
assertStatus(archiveInsufficient, 3, 'archive 空间不足必须使用退出码 3。');
|
||||
|
||||
const archive = describeBackupSpaceRequirement({
|
||||
dataDir: fixture.dataDir,
|
||||
workDir: fixture.workDir,
|
||||
storageFormat: 'archive',
|
||||
args: {},
|
||||
env: { GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES: '1G' },
|
||||
});
|
||||
const files = describeBackupSpaceRequirement({
|
||||
dataDir: fixture.dataDir,
|
||||
workDir: fixture.workDir,
|
||||
storageFormat: 'files',
|
||||
args: {},
|
||||
env: { GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1G' },
|
||||
});
|
||||
if (
|
||||
archive.requiredFreeBytes !== 1024n ** 3n ||
|
||||
files.requiredFreeBytes !== 1024n ** 3n
|
||||
) {
|
||||
failures.push(
|
||||
`空间口径覆盖参数应生效:archive=${archive.requiredFreeBytes} files=${files.requiredFreeBytes}`,
|
||||
);
|
||||
}
|
||||
if (archive.storageFormat !== 'archive' || files.storageFormat !== 'files') {
|
||||
failures.push('空间预检结果必须回显实际 storage-format。');
|
||||
}
|
||||
}
|
||||
|
||||
function assertStopFailureRetainsRecoveryMarker() {
|
||||
const fixture = createFixture('stop-failure-marker');
|
||||
writeExecutable(
|
||||
@@ -2500,6 +2567,110 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() {
|
||||
}
|
||||
}
|
||||
|
||||
function assertMinimalPlanKeepsOnlyRetainedSnapshotAndTrailingCommitlog() {
|
||||
const fixture = createHistoryFixture('minimal-plan', { nestedData: true });
|
||||
const replicaDir = path.join(fixture.replicasDir, '2');
|
||||
const snapshotsDir = path.join(replicaDir, 'snapshots');
|
||||
const clogDir = path.join(replicaDir, 'clog');
|
||||
mkdirSync(snapshotsDir, { recursive: true });
|
||||
mkdirSync(clogDir, { recursive: true });
|
||||
for (const transaction of ['100', '200', '300']) {
|
||||
const padded = transaction.padStart(20, '0');
|
||||
const snapshotDir = path.join(snapshotsDir, `${padded}.snapshot_dir`);
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(snapshotDir, `${padded}.snapshot_bsatn`),
|
||||
'snapshot',
|
||||
);
|
||||
}
|
||||
for (const transaction of ['50', '150', '250', '350']) {
|
||||
const padded = transaction.padStart(20, '0');
|
||||
writeFileSync(path.join(clogDir, `${padded}.stdb.log`), 'log');
|
||||
writeFileSync(path.join(clogDir, `${padded}.stdb.ofs`), 'ofs');
|
||||
}
|
||||
for (const relativeDir of [
|
||||
'config',
|
||||
'data/control-db',
|
||||
'data/program-bytes',
|
||||
]) {
|
||||
const directory = path.join(fixture.dataDir, relativeDir);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(path.join(directory, 'state.bin'), 'state');
|
||||
}
|
||||
writeFileSync(path.join(fixture.dataDir, 'data/config.toml'), 'config');
|
||||
writeFileSync(path.join(fixture.dataDir, 'data/metadata.toml'), 'metadata');
|
||||
|
||||
const plan = discoverMinimalPlan({ dataDir: fixture.dataDir });
|
||||
const paths = plan.candidates.map((item) => item.path);
|
||||
const expect = (condition, reason) => {
|
||||
if (!condition) {
|
||||
failures.push(reason);
|
||||
}
|
||||
};
|
||||
|
||||
expect(
|
||||
plan.retainSnapshots === 2,
|
||||
`minimal 默认应保留 2 份 snapshot,实际 ${plan.retainSnapshots}`,
|
||||
);
|
||||
expect(
|
||||
paths.includes(
|
||||
'data/replicas/2/snapshots/00000000000000000200.snapshot_dir',
|
||||
),
|
||||
'minimal 必须保留次新 snapshot。',
|
||||
);
|
||||
expect(
|
||||
paths.includes(
|
||||
'data/replicas/2/snapshots/00000000000000000300.snapshot_dir',
|
||||
),
|
||||
'minimal 必须保留最新 snapshot。',
|
||||
);
|
||||
expect(
|
||||
!paths.includes(
|
||||
'data/replicas/2/snapshots/00000000000000000100.snapshot_dir',
|
||||
),
|
||||
'minimal 不得备份更早的 snapshot。',
|
||||
);
|
||||
expect(
|
||||
paths.includes('data/replicas/2/clog/00000000000000000150.stdb.log'),
|
||||
'minimal 必须保留覆盖最老保留 snapshot 的边界 commitlog 段。',
|
||||
);
|
||||
expect(
|
||||
paths.includes('data/replicas/2/clog/00000000000000000350.stdb.log'),
|
||||
'minimal 必须保留最新 commitlog 段。',
|
||||
);
|
||||
expect(
|
||||
!paths.includes('data/replicas/2/clog/00000000000000000050.stdb.log'),
|
||||
'minimal 不得备份更早的 commitlog 段。',
|
||||
);
|
||||
for (const staticPath of [
|
||||
'config',
|
||||
'data/config.toml',
|
||||
'data/metadata.toml',
|
||||
'data/control-db',
|
||||
'data/program-bytes',
|
||||
]) {
|
||||
expect(
|
||||
paths.includes(staticPath),
|
||||
`minimal 必须保留状态路径 ${staticPath}。`,
|
||||
);
|
||||
}
|
||||
const replica = plan.replicas.find((item) => item.replicaId === '2');
|
||||
expect(
|
||||
replica?.retainedSnapshots === 2 && replica?.droppedSnapshots === 1,
|
||||
'minimal 必须报告保留/丢弃的 snapshot 数量。',
|
||||
);
|
||||
expect(
|
||||
replica?.droppedSegments === 1,
|
||||
`minimal 必须报告丢弃的 commitlog 段数量,实际 ${replica?.droppedSegments}`,
|
||||
);
|
||||
|
||||
assertThrows(
|
||||
() => discoverMinimalPlan({ dataDir: fixture.dataDir, retainSnapshots: 0 }),
|
||||
'--retain-snapshots 必须是 >= 1 的整数',
|
||||
'minimal 必须校验保留 snapshot 数量。',
|
||||
);
|
||||
}
|
||||
|
||||
function createHistoryFixture(name, { nestedData }) {
|
||||
const root = path.join(tmpRoot, name);
|
||||
const dataDir = path.join(root, 'stdb');
|
||||
@@ -2695,7 +2866,7 @@ exit 2
|
||||
return { root, binDir, dataDir, workDir, systemctlLog, tarLog };
|
||||
}
|
||||
|
||||
function runBackup(fixture, extraArgs = []) {
|
||||
function runBackup(fixture, extraArgs = [], envOverrides = {}) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -2720,6 +2891,7 @@ function runBackup(fixture, extraArgs = []) {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
...envOverrides,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,18 +26,6 @@ const aiGameCreatorShellAppSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src/App.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const aiGameCreatorShellAppModelSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src/features/app-shell/model.ts',
|
||||
'utf8',
|
||||
);
|
||||
const aiGameCreatorShellProjectWorkspaceChatPaneSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const aiGameCreatorShellDeveloperProjectPanelsSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/DeveloperProjectPanels.tsx',
|
||||
'utf8',
|
||||
);
|
||||
const aiGameCreatorLocalGamePreviewFrameSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx',
|
||||
'utf8',
|
||||
@@ -87,8 +75,8 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
|
||||
// - agc-web:AI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
|
||||
// - agc-rust-crates:AGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
|
||||
// 无锁独立 crate),只需 server-rs 侧的依赖预热。
|
||||
// - agc-rust-shard-1..4:AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,一片一个
|
||||
// 分组(CI 里就是一个 job),片内仍保持 `--test-threads=1`;每个分片分组都会做一次
|
||||
// - agc-rust-shard-1..4:AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,片内仍保持
|
||||
// `--test-threads=1`;CI 由两条 lane job 各顺序运行两片,每个分片分组都会做一次
|
||||
// 「片并集等于全集且互斥」的自校验。
|
||||
// - agc-rust-smoke:会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run smoke。
|
||||
// 与分片分开,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
@@ -2321,7 +2309,7 @@ const steps = [
|
||||
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
|
||||
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
|
||||
// web、rust-crates、rust 分片、smoke 四段,整体顺序与 `npm run ai-game-creator-shell:check`
|
||||
// 完全一致;CI 把每段(以及每个 rust 分片)放进不同 job 并行执行,本地全量运行仍然是
|
||||
// 完全一致;CI 把各组放进 job,Rust 分片由两条 lane 顺序承载,本地全量运行仍然是
|
||||
// web -> rust(crates -> shards) -> smoke 原顺序。
|
||||
{
|
||||
group: 'agc-web',
|
||||
@@ -2329,12 +2317,41 @@ const steps = [
|
||||
command: npmCommand,
|
||||
args: ['run', 'ai-game-creator-shell:check:web'],
|
||||
},
|
||||
{
|
||||
group: 'agc-web',
|
||||
label: 'agc-plugin-entry-tests',
|
||||
command: npmCommand,
|
||||
args: ['run', 'agc:plugins:test'],
|
||||
},
|
||||
{
|
||||
group: 'agc-rust-crates',
|
||||
label: 'ai-game-creator-shell-check-rust-crates',
|
||||
command: npmCommand,
|
||||
args: ['run', 'ai-game-creator-shell:check:rust:crates'],
|
||||
},
|
||||
{
|
||||
group: 'agc-rust-crates',
|
||||
label: 'agc-plugin-native-tests',
|
||||
command: npmCommand,
|
||||
args: ['run', 'agc:plugins:native-test'],
|
||||
},
|
||||
{
|
||||
group: 'agc-rust-shard-1',
|
||||
label: 'ai-game-creator-prompt-source-contracts',
|
||||
command: 'cargo',
|
||||
args: [
|
||||
'test',
|
||||
'--locked',
|
||||
'--manifest-path',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
'--test',
|
||||
'runtime_prompt_bundle_build',
|
||||
'--test',
|
||||
'prompt_source_boundaries',
|
||||
'--',
|
||||
'--test-threads=1',
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'agc-rust-shard-1',
|
||||
label: 'ai-game-creator-shell-check-rust-shard-1',
|
||||
@@ -2594,71 +2611,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'function isDeveloperMode()',
|
||||
'if (!import.meta.env.DEV)',
|
||||
"return params.has('dev') || window.location.hash === '#dev';",
|
||||
]) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellAppModelSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator developer mode boundary drifted: missing ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const snippet of [
|
||||
'projectSupervisorOnly ? false : isDeveloperMode()',
|
||||
'{devMode ? (',
|
||||
'className="developer-pane"',
|
||||
]) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator user/dev UI boundary drifted: missing ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!aiGameCreatorShellAppSource.includes('<ProjectWorkspaceChatPane') ||
|
||||
!aiGameCreatorShellProjectWorkspaceChatPaneSource.includes(
|
||||
'className="chat-pane"',
|
||||
)
|
||||
) {
|
||||
throw new Error('AI game creator user chat pane boundary drifted');
|
||||
}
|
||||
|
||||
const developerProjectPanelIndexes = [
|
||||
...aiGameCreatorShellAppSource.matchAll(/<DeveloperProjectPanels\b/g),
|
||||
].map((match) => match.index ?? -1);
|
||||
const previewFrameCount = [
|
||||
...aiGameCreatorShellDeveloperProjectPanelsSource.matchAll(/<iframe\b/g),
|
||||
].length;
|
||||
const devModeBranchIndex =
|
||||
aiGameCreatorShellAppSource.indexOf('{devMode ? (');
|
||||
const developerPaneIndex = aiGameCreatorShellAppSource.indexOf(
|
||||
'className="developer-pane"',
|
||||
);
|
||||
if (previewFrameCount !== 1) {
|
||||
throw new Error(
|
||||
'AI game creator developer pane preview frame count drifted',
|
||||
);
|
||||
}
|
||||
if (developerProjectPanelIndexes.length !== 1) {
|
||||
throw new Error(
|
||||
'AI game creator developer project panels mount count drifted',
|
||||
);
|
||||
}
|
||||
if (
|
||||
devModeBranchIndex < 0 ||
|
||||
developerPaneIndex < 0 ||
|
||||
developerProjectPanelIndexes.some(
|
||||
(index) => index < devModeBranchIndex || index < developerPaneIndex,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator preview panels must stay inside the dev-only pane',
|
||||
);
|
||||
}
|
||||
// 上面几条只约束 DeveloperProjectPanels 自身的 iframe 数量和挂载位置,管不到 App.tsx
|
||||
// 直接内嵌 iframe 的情况——预览必须一律委托给客户端工作台,外壳自己不持有预览框。
|
||||
// 预览必须一律委托给客户端工作台,外壳自己不持有预览框。
|
||||
if ([...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g)].length !== 0) {
|
||||
throw new Error(
|
||||
'AI game creator app shell must delegate preview iframe to the client workbench',
|
||||
@@ -2731,19 +2684,6 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
'AI game creator normal startup must not automatically open a developer window',
|
||||
);
|
||||
}
|
||||
for (const snippet of [
|
||||
'fn open_project_supervisor_chat_window(',
|
||||
'#[cfg(not(debug_assertions))]',
|
||||
'项目总控对话窗口仅在开发构建中可用',
|
||||
'index.html?supervisor-chat&projectPath=',
|
||||
]) {
|
||||
if (!sourceIncludesSnippet(aiGameCreatorShellTauriSource, snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator supervisor chat window must stay developer-only: ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
|
||||
'fn open_game_creator_workspace_window(',
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ export const REQUIRED_WORKSPACES = Object.freeze([
|
||||
'packages/model3d-viewer',
|
||||
'packages/shared',
|
||||
'plugins/agc-cocos-editor',
|
||||
'plugins/agc-unity-editor',
|
||||
'plugins/agc-godot-editor',
|
||||
'tools/spine-json-export-validator',
|
||||
]);
|
||||
|
||||
@@ -31,6 +33,8 @@ const WORKSPACE_NAMES = Object.freeze({
|
||||
'packages/model3d-viewer': '@genarrative/model3d-viewer',
|
||||
'packages/shared': '@genarrative/shared',
|
||||
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
|
||||
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
|
||||
'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor',
|
||||
'tools/spine-json-export-validator':
|
||||
'@genarrative/spine-json-export-validator',
|
||||
});
|
||||
@@ -52,6 +56,8 @@ const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
|
||||
'@genarrative/image-canvas-core',
|
||||
],
|
||||
'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'],
|
||||
'plugins/agc-unity-editor/package.json': ['@genarrative/agc-plugin-sdk'],
|
||||
'plugins/agc-godot-editor/package.json': ['@genarrative/agc-plugin-sdk'],
|
||||
});
|
||||
|
||||
const DEPENDENCY_FIELDS = Object.freeze([
|
||||
|
||||
@@ -24,6 +24,8 @@ const workspaceNames = {
|
||||
'packages/model3d-viewer': '@genarrative/model3d-viewer',
|
||||
'packages/shared': '@genarrative/shared',
|
||||
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
|
||||
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
|
||||
'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor',
|
||||
'tools/spine-json-export-validator':
|
||||
'@genarrative/spine-json-export-validator',
|
||||
};
|
||||
@@ -38,6 +40,8 @@ const localDependencies = {
|
||||
'apps/mobile-shell': { '@genarrative/shared': '0.1.0' },
|
||||
'packages/image-canvas-react': { '@genarrative/image-canvas-core': '0.1.0' },
|
||||
'plugins/agc-cocos-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
|
||||
'plugins/agc-unity-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
|
||||
'plugins/agc-godot-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ const checks = [
|
||||
{
|
||||
file: 'package.json',
|
||||
includes:
|
||||
'"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check"',
|
||||
'"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check"',
|
||||
reason: '仓库必须保留统一、只读的 Rust workspace 格式检查入口。',
|
||||
},
|
||||
{
|
||||
@@ -42,6 +42,18 @@ const checks = [
|
||||
reason:
|
||||
'Copy Artifact Production 模式下,API Build 必须显式授权 API Deploy 读取归档。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.agc-global-version-issue',
|
||||
includes: "copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,",
|
||||
reason:
|
||||
'Copy Artifact Production 模式下,AGC 发号 Job 必须显式授权调度管线读取 agc-global-version.txt,否则整轮调度会在 copyArtifacts 处失败。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.agc-global-version-issue',
|
||||
includes: "Genarrative-Manual-Build-And-Deploy')",
|
||||
reason:
|
||||
'Copy Artifact Production 模式下,AGC 发号 Job 还必须授权手动发布管线读取总号,否则用户触发的手动发布会停在 copyArtifacts(SYSTEM 定时构建不受影响)。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'npm run check:rustfmt',
|
||||
@@ -246,6 +258,50 @@ const checks = [
|
||||
reason:
|
||||
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes:
|
||||
'precheck_backup_space_before_maintenance\n\n"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"',
|
||||
reason:
|
||||
'备份空间预检必须先于进入维护模式执行:磁盘不足时不得停服务或把生产留在维护态。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--check-space-only',
|
||||
reason:
|
||||
'生产 Stdb publish 必须复用备份脚本的 --check-space-only 预检,保证空间口径与真实备份一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK',
|
||||
reason:
|
||||
'archive 冷备份空间不足时必须能自动降级为 files 存储格式(不落地本地归档)。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'timeout waiting for transaction confirmation',
|
||||
reason:
|
||||
'publish 客户端等确认超时不能直接判失败:必须重试同版本 publish 以确认模块是否已生效,避免把生产留在维护态。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'restore_runtime_services_before_publish',
|
||||
reason:
|
||||
'尚未开始 publish 的失败必须自动恢复运行时服务并退出维护,避免生产停在维护态等人工。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes:
|
||||
'if [[ "${PUBLISH_STARTED}" -ne 1 && "${AUTO_RECOVER_BEFORE_PUBLISH}" == "1" ]]; then',
|
||||
reason:
|
||||
'只有尚未开始 publish 的失败才允许自动恢复并退出维护,半发布状态必须保持维护。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/database-backup-to-oss.mjs',
|
||||
includes: 'SPACE_INSUFFICIENT_EXIT_CODE',
|
||||
reason:
|
||||
'备份空间不足必须使用独立退出码,调用方据此决定降级存储格式而不是一律失败。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'stop_runtime_services_for_rollout_gate',
|
||||
@@ -888,7 +944,7 @@ const checks = [
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'archive-full|files-history)',
|
||||
includes: 'archive-full|files-history|files-minimal)',
|
||||
reason: 'Server-Provision 必须拒绝未知数据库备份 profile。',
|
||||
},
|
||||
{
|
||||
@@ -913,7 +969,7 @@ const checks = [
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-server-provision',
|
||||
includes:
|
||||
"choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history']",
|
||||
"choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-minimal', 'files-history']",
|
||||
reason:
|
||||
'Server-Provision Job 必须显式暴露 archive-first 的数据库备份 profile。',
|
||||
},
|
||||
@@ -966,13 +1022,31 @@ const checks = [
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-server-provision',
|
||||
includes: 'release 仅允许 archive-full;files-history',
|
||||
includes: 'release 不允许 files-history',
|
||||
reason:
|
||||
'release 必须拒绝 files-history,避免逐文件 catalog 扫描再次触发生产内存峰值。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-server-provision',
|
||||
includes: 'files-minimal',
|
||||
reason:
|
||||
'release 定时备份必须提供 files-minimal profile:只备最近 snapshot 与其后 commitlog,热备不停服。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/systemd/genarrative-database-backup-files-minimal.conf',
|
||||
includes: '--mode full --minimal --retain-snapshots 2 --freeze-dir',
|
||||
reason:
|
||||
'files-minimal 定时备份必须使用 minimal 口径并保留上游默认的最近 2 份 snapshot。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--freeze-dir',
|
||||
reason: '发布前备份默认使用 minimal 热备,避免 40G 级冷备空间门槛与停服。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/database-backup-to-oss.mjs',
|
||||
includes: 'assertSufficientWorkDirSpace({dataDir, workDir, args, env})',
|
||||
includes:
|
||||
"assertSufficientWorkDirSpace({ dataDir, workDir, args, env, storageFormat: 'archive' })",
|
||||
normalizeWhitespace: true,
|
||||
reason: '生产冷备份必须先做工作目录剩余空间预检,避免停库后写满磁盘。',
|
||||
},
|
||||
@@ -7643,6 +7717,14 @@ const agcPipelineContent = readFileSync(
|
||||
'jenkins/Jenkinsfile.ai-game-creator-shell-build',
|
||||
'utf8',
|
||||
);
|
||||
const agcMacosPipelineContent = readFileSync(
|
||||
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
|
||||
'utf8',
|
||||
);
|
||||
const notifyEmailPipelineContent = readFileSync(
|
||||
'jenkins/Jenkinsfile.production-notify-email',
|
||||
'utf8',
|
||||
);
|
||||
const scheduledRevisionTriggerContent = readFileSync(
|
||||
'jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||||
'utf8',
|
||||
@@ -7665,6 +7747,49 @@ for (const [file, content] of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, content] of [
|
||||
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
|
||||
[
|
||||
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
|
||||
agcMacosPipelineContent,
|
||||
],
|
||||
]) {
|
||||
for (const [snippet, reason] of [
|
||||
[
|
||||
"build job: 'Genarrative-Notify-Email'",
|
||||
'客户端打包管线必须触发统一邮件通知 Job。',
|
||||
],
|
||||
[
|
||||
"string(name: 'OSS_DOWNLOAD_URL', value: ossDownloadUrl)",
|
||||
'客户端打包管线必须把本次 OSS 首装包链接传给邮件通知 Job。',
|
||||
],
|
||||
[
|
||||
"string(name: 'NOTIFICATION_EMAILS'",
|
||||
'客户端打包管线必须支持追加邮件收件人。',
|
||||
],
|
||||
['latest.json', '客户端打包管线必须从本次生成的渠道清单读取下载链接。'],
|
||||
]) {
|
||||
if (!content.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(`[check:production-ops] ${file} ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [snippet, reason] of [
|
||||
[
|
||||
"string(name: 'OSS_DOWNLOAD_URL'",
|
||||
'统一邮件通知 Job 必须接收 OSS 下载链接参数。',
|
||||
],
|
||||
['OSS 下载链接:', '统一邮件通知正文必须展示 OSS 下载链接。'],
|
||||
]) {
|
||||
if (!notifyEmailPipelineContent.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] Jenkinsfile.production-notify-email ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [snippet, reason] of [
|
||||
["cron('H * * * *')", '必须每小时检查一次远端版本'],
|
||||
['disableConcurrentBuilds()', '必须禁止并发触发,避免同一版本重复触发下游'],
|
||||
@@ -7697,12 +7822,50 @@ for (const [snippet, reason] of [
|
||||
const scheduledPinnedParameterCalls = scheduledRevisionTriggerContent.match(
|
||||
/parameters: pinnedParameters/gu,
|
||||
);
|
||||
if ((scheduledPinnedParameterCalls?.length ?? 0) !== 2) {
|
||||
// AGC 客户端版本号统一由发号 Job 产生:调度管线只能在 pinnedParameters 之上
|
||||
// 追加 AGC_RELEASE_VERSION,不能另起一份 revision 或自己递增渠道版本。
|
||||
if (
|
||||
(scheduledPinnedParameterCalls?.length ?? 0) !== 1 ||
|
||||
!scheduledRevisionTriggerContent.includes(
|
||||
'def agcParameters = pinnedParameters + [',
|
||||
)
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
'[check:production-ops] 调度管线必须用同一份 pinnedParameters 同时触发 Full Build 与 AGC Windows Build。',
|
||||
'[check:production-ops] 调度管线必须让 Full Build 与 AGC Windows Build 共用同一份 pinnedParameters(AGC 只允许追加发号 Job 下发的总版本号)。',
|
||||
);
|
||||
}
|
||||
for (const [snippet, reason] of [
|
||||
[
|
||||
"AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'",
|
||||
'必须把客户端版本号收口到专用发号 Job',
|
||||
],
|
||||
[
|
||||
"string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION)",
|
||||
'必须把发号 Job 下发的总版本号透传给 AGC Windows Build',
|
||||
],
|
||||
[
|
||||
"AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'",
|
||||
'必须从发号 Job 的归档产物读取总版本号',
|
||||
],
|
||||
[
|
||||
"AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'",
|
||||
'必须把 macOS 渠道构建纳入同一轮调度',
|
||||
],
|
||||
[
|
||||
'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters',
|
||||
'必须用独立参数列表触发 macOS 渠道构建(pinnedParameters 只允许 Full Build 使用一次)',
|
||||
],
|
||||
[
|
||||
"booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)",
|
||||
'必须让节点离线期间排队的 macOS 旧构建自行让位,不发布过期版本',
|
||||
],
|
||||
]) {
|
||||
if (!scheduledRevisionTriggerContent.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(`[check:production-ops] 调度管线${reason}。`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!scheduledRevisionTriggerJobConfig.includes(
|
||||
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,18 @@ usage() {
|
||||
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
|
||||
发布成功后会补齐生产 API/worker env 的固定 bootstrap secret FILE 路径,再重启并验活重启前 active 的服务。
|
||||
--keep-maintenance-mode 会在 publish 前停止旧 API/controller/worker,并在成功后保持维护态,交由后续 API deploy 恢复服务。
|
||||
发布前先做备份空间预检(不进入维护、不停服务);archive 空间不足且未显式关闭自动降级时,
|
||||
自动改用 files 存储格式(不落地本地归档,改为同步直传 OSS),避免磁盘不足把生产留在维护态。
|
||||
|
||||
环境变量:
|
||||
GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT=archive|files(默认 archive)
|
||||
GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=1|0(默认 1:archive 空间不足自动降级 files)
|
||||
GENARRATIVE_STDB_PUBLISH_BACKUP_MINIMAL=1|0(默认 1:发布前备份只保留最近 N 份 snapshot + 其后 commitlog,热备不停服)
|
||||
GENARRATIVE_STDB_PUBLISH_BACKUP_RETAIN_SNAPSHOTS=N(默认 2,仅在 minimal 模式下生效)
|
||||
GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE=1|0(默认 1:尚未开始 publish 的失败自动恢复服务并退出维护)
|
||||
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
|
||||
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
|
||||
失败时保留维护模式。
|
||||
失败时:尚未开始 publish 的失败会自动恢复运行时服务并退出维护;真正开始 publish 之后的失败保留维护模式。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -55,7 +64,15 @@ API_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_API_ENV_FILE:-/etc/genarrative/api-serv
|
||||
WORKER_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
|
||||
KEEP_MAINTENANCE_MODE=0
|
||||
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
|
||||
BACKUP_STORAGE_FORMAT="${GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT:-archive}"
|
||||
AUTO_FILES_FALLBACK="${GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK:-1}"
|
||||
AUTO_RECOVER_BEFORE_PUBLISH="${GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE:-1}"
|
||||
BACKUP_MINIMAL="${GENARRATIVE_STDB_PUBLISH_BACKUP_MINIMAL:-1}"
|
||||
BACKUP_RETAIN_SNAPSHOTS="${GENARRATIVE_STDB_PUBLISH_BACKUP_RETAIN_SNAPSHOTS:-2}"
|
||||
DEPLOY_COMPLETED=0
|
||||
PUBLISH_STARTED=0
|
||||
MAINTENANCE_ENTERED=0
|
||||
STOPPED_RUNTIME_SERVICES=()
|
||||
PUBLISH_TMP_DIR=""
|
||||
ASYNC_BACKUP_STATUS_FILE=""
|
||||
ASYNC_BACKUP_SCRIPT=""
|
||||
@@ -287,6 +304,114 @@ restart_runtime_services_after_bootstrap_secret_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
backup_script_path() {
|
||||
local candidate=""
|
||||
for candidate in \
|
||||
"${SCRIPT_DIR}/../database-backup-to-oss.mjs" \
|
||||
"${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"; do
|
||||
if [[ -f "${candidate}" ]]; then
|
||||
printf '%s\n' "${candidate}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_backup_space_precheck() {
|
||||
local storage_format="$1"
|
||||
local backup_script=""
|
||||
if ! backup_script="$(backup_script_path)"; then
|
||||
echo "[production-stdb-publish] 缺少数据库备份脚本,无法做备份空间预检" >&2
|
||||
return 1
|
||||
fi
|
||||
node -- "${backup_script}" \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--storage-format "${storage_format}" \
|
||||
--check-space-only
|
||||
}
|
||||
|
||||
# 空间预检必须发生在进入维护模式与停服务之前:磁盘不够时不允许再动生产。
|
||||
precheck_backup_space_before_maintenance() {
|
||||
if [[ "${BACKUP_MODE}" == "skip" ]]; then
|
||||
echo "[production-stdb-publish] 已跳过发布前备份空间预检(--backup-mode skip)"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${BACKUP_MINIMAL}" == "1" ]]; then
|
||||
# minimal 备份是热备:只保留最近 N 份 snapshot + 其后 commitlog,不落地归档也不停服务。
|
||||
BACKUP_STORAGE_FORMAT="files"
|
||||
if [[ "${BACKUP_MODE}" == "async" ]]; then
|
||||
echo "[production-stdb-publish] minimal 备份为同步热备(无本地归档),备份模式由 async 调整为 sync。" >&2
|
||||
BACKUP_MODE="sync"
|
||||
fi
|
||||
fi
|
||||
|
||||
local status=0
|
||||
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
|
||||
if [[ "${status}" -eq 0 ]]; then
|
||||
echo "[production-stdb-publish] 发布前备份空间预检通过: storage-format=${BACKUP_STORAGE_FORMAT}(尚未进入维护模式、未停服务)"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${status}" -ne 3 ]]; then
|
||||
echo "[production-stdb-publish] 发布前备份空间预检失败(非空间原因),中止发布;未进入维护模式、未停服务。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" ]]; then
|
||||
echo "[production-stdb-publish] files 模式备份空间仍不足,中止发布;未进入维护模式、未停服务。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${AUTO_FILES_FALLBACK}" != "1" ]]; then
|
||||
echo "[production-stdb-publish] archive 备份空间不足且已禁用自动降级(GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=${AUTO_FILES_FALLBACK}),中止发布;未进入维护模式、未停服务。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] archive 冷备份空间不足:自动降级为 files 存储格式(不落地本地归档,改为文件级 catalog 直传 OSS)。" >&2
|
||||
BACKUP_STORAGE_FORMAT="files"
|
||||
if [[ "${BACKUP_MODE}" == "async" ]]; then
|
||||
echo "[production-stdb-publish] files 模式不支持 --defer-upload,本次备份改为同步执行。" >&2
|
||||
BACKUP_MODE="sync"
|
||||
fi
|
||||
status=0
|
||||
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
|
||||
if [[ "${status}" -ne 0 ]]; then
|
||||
echo "[production-stdb-publish] 降级为 files 后空间预检仍失败,中止发布;未进入维护模式、未停服务。" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[production-stdb-publish] 已降级为 files 存储格式且空间预检通过。"
|
||||
}
|
||||
|
||||
# 仅在「尚未开始 publish」的失败路径调用:把本次停掉的运行时服务拉回来。
|
||||
restore_runtime_services_before_publish() {
|
||||
if [[ "${#STOPPED_RUNTIME_SERVICES[@]}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
local service=""
|
||||
local state=""
|
||||
local attempt=0
|
||||
echo "[production-stdb-publish] 发布尚未开始,恢复本次停掉的运行时服务: ${STOPPED_RUNTIME_SERVICES[*]}"
|
||||
if ! run_privileged systemctl start "${STOPPED_RUNTIME_SERVICES[@]}"; then
|
||||
echo "[production-stdb-publish] 启动运行时服务失败: ${STOPPED_RUNTIME_SERVICES[*]}" >&2
|
||||
return 1
|
||||
fi
|
||||
for service in "${STOPPED_RUNTIME_SERVICES[@]}"; do
|
||||
state=""
|
||||
for attempt in $(seq 1 15); do
|
||||
state="$(get_runtime_service_active_state "${service}" 2>/dev/null || true)"
|
||||
if [[ "${state}" == "active" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "${state}" != "active" ]]; then
|
||||
echo "[production-stdb-publish] 运行时服务未恢复 active: ${service}, state=${state}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[production-stdb-publish] 运行时服务已恢复 active: ${service}"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
stop_runtime_services_for_rollout_gate() {
|
||||
local api_state=""
|
||||
local controller_state=""
|
||||
@@ -326,6 +451,7 @@ stop_runtime_services_for_rollout_gate() {
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] 停止旧运行时服务并保持维护态: ${services_to_stop[*]}"
|
||||
STOPPED_RUNTIME_SERVICES=("${services_to_stop[@]}")
|
||||
run_privileged systemctl stop "${services_to_stop[@]}"
|
||||
for worker_service in "${services_to_stop[@]}"; do
|
||||
if [[ "$(get_runtime_service_active_state "${worker_service}")" == "active" ]]; then
|
||||
@@ -402,6 +528,10 @@ while [[ $# -gt 0 ]]; do
|
||||
BACKUP_MODE="${2:?缺少 --backup-mode 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--backup-storage-format)
|
||||
BACKUP_STORAGE_FORMAT="${2:?缺少 --backup-storage-format 的值}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "[production-stdb-publish] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -431,6 +561,14 @@ for runtime_env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! "${BACKUP_STORAGE_FORMAT}" =~ ^(archive|files)$ ]]; then
|
||||
echo "[production-stdb-publish] --backup-storage-format 只能是 archive 或 files: ${BACKUP_STORAGE_FORMAT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" && "${BACKUP_MODE}" == "async" ]]; then
|
||||
echo "[production-stdb-publish] files 存储格式不支持 --defer-upload,备份模式由 async 调整为 sync" >&2
|
||||
BACKUP_MODE="sync"
|
||||
fi
|
||||
if [[ ! "${BACKUP_MODE}" =~ ^(async|sync|skip)$ ]]; then
|
||||
echo "[production-stdb-publish] --backup-mode 只能是 async、sync 或 skip: ${BACKUP_MODE}" >&2
|
||||
exit 1
|
||||
@@ -497,7 +635,22 @@ on_exit() {
|
||||
rm -rf "${PUBLISH_TMP_DIR}"
|
||||
fi
|
||||
if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then
|
||||
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
|
||||
if [[ "${PUBLISH_STARTED}" -ne 1 && "${AUTO_RECOVER_BEFORE_PUBLISH}" == "1" ]]; then
|
||||
# 尚未开始 publish 就失败(例如备份空间/备份执行失败):本次没有任何发布变更,
|
||||
# 必须把停掉的运行时服务拉回来并退出维护,避免生产停在维护态等人工救。
|
||||
if restore_runtime_services_before_publish; then
|
||||
if [[ "${MAINTENANCE_ENTERED}" -eq 1 ]]; then
|
||||
if ! "${SCRIPT_DIR}/maintenance-off.sh"; then
|
||||
echo "[production-stdb-publish] 自动退出维护模式失败,请手工执行 maintenance-off.sh。" >&2
|
||||
fi
|
||||
fi
|
||||
echo "[production-stdb-publish] 发布尚未开始即失败,已自动恢复运行时服务并退出维护模式。"
|
||||
else
|
||||
echo "[production-stdb-publish] 自动恢复运行时服务失败,保持维护模式,请手工处理。" >&2
|
||||
fi
|
||||
else
|
||||
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
|
||||
fi
|
||||
fi
|
||||
exit "${exit_code}"
|
||||
}
|
||||
@@ -506,12 +659,8 @@ trap on_exit EXIT
|
||||
|
||||
prepare_async_backup() {
|
||||
local -a restart_service_args=()
|
||||
ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
|
||||
ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
fi
|
||||
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
|
||||
echo "[production-stdb-publish] 缺少数据库备份脚本: ${ASYNC_BACKUP_SCRIPT}" >&2
|
||||
if ! ASYNC_BACKUP_SCRIPT="$(backup_script_path)"; then
|
||||
echo "[production-stdb-publish] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -527,6 +676,7 @@ prepare_async_backup() {
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--storage-format "${BACKUP_STORAGE_FORMAT}" \
|
||||
--stop-service spacetimedb.service \
|
||||
"${restart_service_args[@]}" \
|
||||
--defer-upload \
|
||||
@@ -676,7 +826,10 @@ wait_for_api_healthz_ready() {
|
||||
return 1
|
||||
}
|
||||
|
||||
precheck_backup_space_before_maintenance
|
||||
|
||||
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
|
||||
MAINTENANCE_ENTERED=1
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
|
||||
stop_runtime_services_for_rollout_gate
|
||||
fi
|
||||
@@ -687,25 +840,34 @@ case "${BACKUP_MODE}" in
|
||||
;;
|
||||
sync)
|
||||
SYNC_BACKUP_RESTART_SERVICE_ARGS=()
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
fi
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
|
||||
if ! BACKUP_SCRIPT="$(backup_script_path)"; then
|
||||
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
|
||||
SYNC_BACKUP_RESTART_SERVICE_ARGS+=(--restart-service-after genarrative-api.service)
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布"
|
||||
node -- "${BACKUP_SCRIPT}" \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--stop-service spacetimedb.service \
|
||||
"${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}"
|
||||
SYNC_BACKUP_ARGS=(
|
||||
--env-file /etc/genarrative/api-server.env
|
||||
--data-dir "${SPACETIME_ROOT_DIR}"
|
||||
--database "${DATABASE}"
|
||||
--storage-format "${BACKUP_STORAGE_FORMAT}"
|
||||
)
|
||||
if [[ "${BACKUP_MINIMAL}" == "1" ]]; then
|
||||
echo "[production-stdb-publish] publish 前执行 minimal 热备(最近 ${BACKUP_RETAIN_SNAPSHOTS} 份 snapshot + 其后 commitlog),不停服务"
|
||||
SYNC_BACKUP_ARGS+=(
|
||||
--mode full
|
||||
--minimal
|
||||
--retain-snapshots "${BACKUP_RETAIN_SNAPSHOTS}"
|
||||
--freeze-dir "${GENARRATIVE_STDB_PUBLISH_BACKUP_FREEZE_DIR:-/var/lib/genarrative/database-backups/publish-minimal-freeze}"
|
||||
)
|
||||
else
|
||||
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份(storage-format=${BACKUP_STORAGE_FORMAT}),失败会阻断发布"
|
||||
SYNC_BACKUP_ARGS+=(--stop-service spacetimedb.service)
|
||||
SYNC_BACKUP_ARGS+=("${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}")
|
||||
fi
|
||||
node -- "${BACKUP_SCRIPT}" "${SYNC_BACKUP_ARGS[@]}"
|
||||
;;
|
||||
skip)
|
||||
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
||||
@@ -742,6 +904,34 @@ else
|
||||
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}, root=${SPACETIME_ROOT_DIR}"
|
||||
fi
|
||||
|
||||
# 迁移已提交但客户端等确认超时(HTTP 504 / timeout waiting for transaction confirmation)
|
||||
# 时不能直接判失败:重试一次同版本 publish,SpacetimeDB 对已生效的同版本是幂等的 no-op,
|
||||
# 重试成功即说明目标模块已在位,避免把生产留在维护态。
|
||||
run_spacetime_publish() {
|
||||
local attempt=1
|
||||
local output=""
|
||||
local status=0
|
||||
while :; do
|
||||
output=""
|
||||
status=0
|
||||
if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
output="$(runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}" 2>&1)" || status=$?
|
||||
else
|
||||
output="$(spacetime "${PUBLISH_ARGS[@]}" 2>&1)" || status=$?
|
||||
fi
|
||||
printf '%s\n' "${output}"
|
||||
if [[ "${status}" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${attempt}" -ge 2 || "${output}" != *"timeout waiting for transaction confirmation"* ]]; then
|
||||
return "${status}"
|
||||
fi
|
||||
echo "[production-stdb-publish] publish 客户端等事务确认超时,可能迁移已提交;重试一次同版本 publish 以确认模块状态。" >&2
|
||||
attempt=$((attempt + 1))
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
if ! id "${RUN_AS_USER}" >/dev/null 2>&1; then
|
||||
echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2
|
||||
@@ -768,9 +958,11 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
else
|
||||
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
|
||||
fi
|
||||
runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}"
|
||||
PUBLISH_STARTED=1
|
||||
run_spacetime_publish
|
||||
else
|
||||
spacetime "${PUBLISH_ARGS[@]}"
|
||||
PUBLISH_STARTED=1
|
||||
run_spacetime_publish
|
||||
fi
|
||||
|
||||
RUNTIME_SERVICE_BOOTSTRAP_SECRET_DIR="$(dirname "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}")"
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile"
|
||||
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260807.1}"
|
||||
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260920.2}"
|
||||
runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}"
|
||||
|
||||
write_build_context_file_list() {
|
||||
@@ -29,7 +29,10 @@ write_build_context_file_list() {
|
||||
server-rs/Cargo.lock \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/desktop-shell/src-tauri/Cargo.lock
|
||||
find server-rs/crates -name Cargo.toml -print0 | sort -z
|
||||
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
|
||||
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
|
||||
-type f -print0 \
|
||||
| sort -z
|
||||
}
|
||||
|
||||
usage() {
|
||||
@@ -87,7 +90,9 @@ case "${command_name}" in
|
||||
server-rs/Cargo.lock \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/desktop-shell/src-tauri/Cargo.lock
|
||||
find server-rs/crates -name Cargo.toml -print0 \
|
||||
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
|
||||
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
|
||||
-type f -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 -r sha256sum
|
||||
} \
|
||||
|
||||
@@ -15,6 +15,8 @@ DATABASE_BACKUP_PROFILE="${DATABASE_BACKUP_PROFILE:-archive-full}"
|
||||
DATABASE_BACKUP_FILES_HISTORY_WORK_DIR="${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR:-/var/lib/genarrative/database-backups/files-history}"
|
||||
DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR="/etc/systemd/system/genarrative-database-backup.service.d"
|
||||
DATABASE_BACKUP_FILES_HISTORY_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-files-history.conf"
|
||||
DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR="${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR:-/var/lib/genarrative/database-backups/files-minimal}"
|
||||
DATABASE_BACKUP_FILES_MINIMAL_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-files-minimal.conf"
|
||||
DATABASE_BACKUP_LEGACY_DEV_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-dev-files.conf"
|
||||
|
||||
require_non_root_relative_path() {
|
||||
@@ -71,10 +73,10 @@ validate_server_names() {
|
||||
|
||||
validate_database_backup_profile() {
|
||||
case "${DATABASE_BACKUP_PROFILE}" in
|
||||
archive-full|files-history)
|
||||
archive-full|files-history|files-minimal)
|
||||
;;
|
||||
*)
|
||||
echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-full 或 files-history,当前值: ${DATABASE_BACKUP_PROFILE}" >&2
|
||||
echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-full、files-history 或 files-minimal,当前值: ${DATABASE_BACKUP_PROFILE}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1323,12 +1325,29 @@ render_database_backup_files_history_drop_in() {
|
||||
deploy/systemd/genarrative-database-backup-files-history.conf
|
||||
}
|
||||
|
||||
render_database_backup_files_minimal_drop_in() {
|
||||
local current_escaped env_escaped work_dir_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
||||
work_dir_escaped="$(escape_sed_replacement "${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR}")"
|
||||
sed \
|
||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
||||
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
|
||||
-e "s|/var/lib/genarrative/database-backups/files-minimal|${work_dir_escaped}|g" \
|
||||
deploy/systemd/genarrative-database-backup-files-minimal.conf
|
||||
}
|
||||
|
||||
configure_database_backup_profile() {
|
||||
local rendered_drop_in
|
||||
|
||||
if [[ "${DATABASE_BACKUP_PROFILE}" == "archive-full" ]]; then
|
||||
echo "[server-provision] 数据库备份 profile=archive-full,保留主 service 的全量冷备行为。"
|
||||
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
|
||||
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${DATABASE_BACKUP_PROFILE}" == "files-minimal" ]]; then
|
||||
configure_database_backup_files_minimal_profile
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -1350,13 +1369,26 @@ configure_database_backup_profile() {
|
||||
|
||||
run_cmd install -d -o genarrative -g genarrative -m 0750 "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}"
|
||||
run_cmd install -d -o root -g root -m 0755 "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}"
|
||||
run_cmd rm -f "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
|
||||
run_cmd rm -f "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
|
||||
rendered_drop_in="$(mktemp)"
|
||||
render_database_backup_files_history_drop_in >"${rendered_drop_in}"
|
||||
install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" 0644
|
||||
rm -f "${rendered_drop_in}"
|
||||
}
|
||||
|
||||
configure_database_backup_files_minimal_profile() {
|
||||
local rendered_drop_in
|
||||
|
||||
echo "[server-provision] 数据库备份 profile=files-minimal,只保留最近 snapshot 与其后 commitlog(热备、不停服)。"
|
||||
run_cmd install -d -o genarrative -g genarrative -m 0750 "${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR}"
|
||||
run_cmd install -d -o root -g root -m 0755 "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}"
|
||||
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
|
||||
rendered_drop_in="$(mktemp)"
|
||||
render_database_backup_files_minimal_drop_in >"${rendered_drop_in}"
|
||||
install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" 0644
|
||||
rm -f "${rendered_drop_in}"
|
||||
}
|
||||
|
||||
render_health_patrol_service() {
|
||||
local current_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
@@ -1372,6 +1404,7 @@ require_path deploy/systemd/genarrative-external-generation-controller.service
|
||||
require_path deploy/systemd/genarrative-bgfilter-worker.service
|
||||
require_path deploy/systemd/genarrative-database-backup.service
|
||||
require_path deploy/systemd/genarrative-database-backup-files-history.conf
|
||||
require_path deploy/systemd/genarrative-database-backup-files-minimal.conf
|
||||
require_path deploy/systemd/genarrative-database-backup.timer
|
||||
require_path deploy/systemd/genarrative-health-patrol.service
|
||||
require_path deploy/systemd/genarrative-health-patrol.timer
|
||||
|
||||
@@ -15,6 +15,16 @@ export const RUSTFMT_WORKSPACES = [
|
||||
prefix: 'apps/ai-game-creator-shell/src-tauri/',
|
||||
manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
},
|
||||
{
|
||||
prefix: 'plugins/agc-unity-editor/native/unity-editor-bridge/',
|
||||
manifestPath:
|
||||
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||||
},
|
||||
{
|
||||
prefix: 'plugins/agc-godot-editor/native/godot-editor-bridge/',
|
||||
manifestPath:
|
||||
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,15 +37,19 @@ describe('lint-staged Rust 格式检查的 workspace 选择', () => {
|
||||
).toEqual(['apps/ai-game-creator-shell/src-tauri/Cargo.toml']);
|
||||
});
|
||||
|
||||
test('两个 workspace 都有暂存文件时两个都查', () => {
|
||||
test('多个 workspace 都有暂存文件时分别检查', () => {
|
||||
expect(
|
||||
selectedManifests([
|
||||
'server-rs/crates/api-server/src/editor_project.rs',
|
||||
'apps/ai-game-creator-shell/src-tauri/src/assets.rs',
|
||||
'plugins/agc-unity-editor/native/unity-editor-bridge/src/lib.rs',
|
||||
'plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs',
|
||||
]),
|
||||
).toEqual([
|
||||
'server-rs/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||||
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,16 +35,29 @@ const apiServerDockerfile = readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const deployContainerReadme = readFileSync(
|
||||
resolve(process.cwd(), 'deploy/container/README.md'),
|
||||
'utf8',
|
||||
);
|
||||
const operationsDoc = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const rustToolchainToml = readFileSync(
|
||||
resolve(process.cwd(), 'rust-toolchain.toml'),
|
||||
'utf8',
|
||||
);
|
||||
const jobNames = [
|
||||
'repository-checks',
|
||||
'frontend-tests',
|
||||
'backend-tests',
|
||||
'native-shell-tests',
|
||||
'ai-game-creator-shell-web-tests',
|
||||
'ai-game-creator-shell-rust-shard-1',
|
||||
'ai-game-creator-shell-rust-shard-2',
|
||||
'ai-game-creator-shell-rust-shard-3',
|
||||
'ai-game-creator-shell-rust-shard-4',
|
||||
'ai-game-creator-shell-rust-lane-1',
|
||||
'ai-game-creator-shell-rust-lane-2',
|
||||
'ai-game-creator-shell-rust-smoke',
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
] as const;
|
||||
@@ -54,10 +67,8 @@ const jobNames = [
|
||||
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
|
||||
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
|
||||
const jobsWithoutNpmInstall: readonly string[] = [
|
||||
'ai-game-creator-shell-rust-shard-1',
|
||||
'ai-game-creator-shell-rust-shard-2',
|
||||
'ai-game-creator-shell-rust-shard-3',
|
||||
'ai-game-creator-shell-rust-shard-4',
|
||||
'ai-game-creator-shell-rust-lane-1',
|
||||
'ai-game-creator-shell-rust-lane-2',
|
||||
'ai-game-creator-shell-rust-smoke',
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
];
|
||||
@@ -167,8 +178,16 @@ describe('project CI workflow', () => {
|
||||
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
|
||||
);
|
||||
expect(imageDockerfile).toContain('ARG NPM_VERSION=10.9.7');
|
||||
// base runner 镜像把 /opt/acttoolcache 的 Node 放在 PATH 最前;固定 Node/npm
|
||||
// 必须写成绝对路径 wrapper 并覆盖 toolcache bin,否则登录与否会解析到不同工具链。
|
||||
expect(imageDockerfile).toContain(
|
||||
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
|
||||
'rm -rf /opt/acttoolcache/node/24.18.0/x64/bin',
|
||||
);
|
||||
expect(imageDockerfile).toContain(
|
||||
'exec /usr/local/lib/genarrative-node/bin/node /usr/local/lib/genarrative-node/lib/node_modules/npm/bin/npm-cli.js "$@"',
|
||||
);
|
||||
expect(imageDockerfile).toContain(
|
||||
'npm install --global --prefix /usr/local/lib/genarrative-node',
|
||||
);
|
||||
expect(imageDockerfile).toContain(
|
||||
'GENARRATIVE_GITEA_CI_NPM_VERSION=${NPM_VERSION}',
|
||||
@@ -248,6 +267,22 @@ describe('project CI workflow', () => {
|
||||
expect(imageDockerignore).toContain(`!${path}`);
|
||||
}
|
||||
|
||||
// AGC 通过本地 path 依赖引用三个编辑器 bridge crate。镜像预热会对
|
||||
// AGC manifest 执行 cargo fetch --locked,构建上下文与 dockerignore
|
||||
// 必须同时放行这些 crate,否则镜像在 cargo fetch 阶段必然失败。
|
||||
for (const bridgeDir of [
|
||||
'plugins/agc-cocos-editor/native/cocos-editor-bridge',
|
||||
'plugins/agc-unity-editor/native/unity-editor-bridge',
|
||||
'plugins/agc-godot-editor/native/godot-editor-bridge',
|
||||
]) {
|
||||
expect(imageBuildScript.split(bridgeDir)).toHaveLength(1);
|
||||
expect(imageDockerignore).toContain(`!${bridgeDir}/`);
|
||||
expect(imageDockerignore).toContain('**');
|
||||
expect(imageDockerfile).toContain(
|
||||
`COPY ${bridgeDir} /tmp/genarrative-cargo-cache/${bridgeDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(imageBuildScript).toContain(
|
||||
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
|
||||
);
|
||||
@@ -416,27 +451,36 @@ describe('project CI workflow', () => {
|
||||
const webJob = jobSection('ai-game-creator-shell-web-tests');
|
||||
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
|
||||
expect(webJob).not.toContain('cargo fetch');
|
||||
expect(nativeShellGateScript).toMatch(
|
||||
/group: 'agc-web',[\s\S]*?args: \['run', 'agc:plugins:test'\]/u,
|
||||
);
|
||||
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
|
||||
'plugins/agc-unity-editor/src/entry.test.mjs',
|
||||
);
|
||||
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
|
||||
'plugins/agc-godot-editor/src/entry.test.mjs',
|
||||
);
|
||||
|
||||
// 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳
|
||||
// 自己那份锁定依赖(server-rs 那份归 crate 级 job)。
|
||||
for (const [index, jobName] of [
|
||||
[1, 'ai-game-creator-shell-rust-shard-1'],
|
||||
[2, 'ai-game-creator-shell-rust-shard-2'],
|
||||
[3, 'ai-game-creator-shell-rust-shard-3'],
|
||||
[4, 'ai-game-creator-shell-rust-shard-4'],
|
||||
// 壳 bin 单测仍按名单分 4 片,但由两条 lane 各顺序运行两片;每条 lane 只预热
|
||||
// 一次 AGC 壳自己的锁定依赖(server-rs 那份归 crate 级 job)。
|
||||
for (const [laneName, indexes] of [
|
||||
['ai-game-creator-shell-rust-lane-1', [1, 2]],
|
||||
['ai-game-creator-shell-rust-lane-2', [3, 4]],
|
||||
] as const) {
|
||||
const shardJob = jobSection(jobName);
|
||||
expect(shardJob).toContain(
|
||||
`run: npm run check:native-shells:agc-rust-shard-${index}`,
|
||||
);
|
||||
expect(shardJob).toContain(
|
||||
const laneJob = jobSection(laneName);
|
||||
for (const index of indexes) {
|
||||
expect(laneJob).toContain(
|
||||
`run: npm run check:native-shells:agc-rust-shard-${index}`,
|
||||
);
|
||||
}
|
||||
expect(laneJob).toContain(
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
);
|
||||
expect(shardJob).toContain('cargo fetch --locked');
|
||||
expect(shardJob).not.toContain('server-rs/Cargo.toml');
|
||||
expect(laneJob).toContain('cargo fetch --locked');
|
||||
expect(laneJob).not.toContain('server-rs/Cargo.toml');
|
||||
}
|
||||
|
||||
// 整套用例不能再作为一条命令串行跑完:每个片 job 都必须落到分片运行器的
|
||||
// 整套用例不能再作为一条命令串行跑完:每个分片调用都必须落到分片运行器的
|
||||
// `--shard-index` 上,4 个 index 各一次。
|
||||
for (const index of [1, 2, 3, 4]) {
|
||||
expect(nativeShellGateScript).toContain(`'--shard-index=${index}'`);
|
||||
@@ -458,6 +502,15 @@ describe('project CI workflow', () => {
|
||||
);
|
||||
expect(cratesJob).toContain('server-rs/Cargo.toml');
|
||||
expect(cratesJob).toContain('cargo fetch --locked');
|
||||
expect(nativeShellGateScript).toMatch(
|
||||
/group: 'agc-rust-crates',[\s\S]*?args: \['run', 'agc:plugins:native-test'\]/u,
|
||||
);
|
||||
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
|
||||
'cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||||
);
|
||||
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
|
||||
'cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
|
||||
);
|
||||
|
||||
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列,
|
||||
// rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。
|
||||
@@ -515,13 +568,30 @@ describe('project CI workflow', () => {
|
||||
for (const manifest of [
|
||||
'server-rs/crates/agent-runtime-core/Cargo.toml',
|
||||
'server-rs/crates/agent-runtime-orchestration/Cargo.toml',
|
||||
'plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml',
|
||||
]) {
|
||||
expect(standaloneStep).toContain(manifest);
|
||||
}
|
||||
// 这两个 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch:
|
||||
// 这些 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch:
|
||||
// 带 --locked 会因为缺少锁文件直接失败。
|
||||
expect(standaloneStep).toContain('cargo fetch \\');
|
||||
expect(standaloneStep).not.toContain('cargo fetch --locked');
|
||||
const unityStep = stepSection(
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
'Prepare Unity plugin Rust dependencies',
|
||||
);
|
||||
expect(unityStep).toContain('cargo fetch --locked');
|
||||
expect(unityStep).toContain(
|
||||
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
|
||||
);
|
||||
const godotStep = stepSection(
|
||||
'ai-game-creator-shell-rust-crates',
|
||||
'Prepare Godot plugin Rust dependencies',
|
||||
);
|
||||
expect(godotStep).toContain('cargo fetch --locked');
|
||||
expect(godotStep).toContain(
|
||||
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
|
||||
);
|
||||
|
||||
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
|
||||
expect(
|
||||
@@ -530,4 +600,54 @@ describe('project CI workflow', () => {
|
||||
cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'),
|
||||
);
|
||||
});
|
||||
// 预构建镜像的 Rust 版本分散在四处:rust-toolchain.toml 的 channel、Dockerfile 的
|
||||
// RUST_IMAGE digest、构建脚本的默认 tag,以及两份运维文档。漏改任何一处都会让 job
|
||||
// 在 runtime 校验里失败(`RUSTUP_AUTO_INSTALL=0` 不允许现场补装),或者让文档指向
|
||||
// 已经不存在的镜像,所以这里把它们钉成同一个事实。
|
||||
it('keeps the prebuilt CI job image Rust version, digest and tag in sync with the ops docs', () => {
|
||||
const channel = rustToolchainToml.match(/^channel = "([^"]+)"$/mu)?.[1];
|
||||
expect(channel).toBeTruthy();
|
||||
const channelValue = channel ?? '';
|
||||
|
||||
const rustImage = imageDockerfile.match(
|
||||
/^ARG RUST_IMAGE=([^\s@]+)@(sha256:[a-f0-9]{64})$/mu,
|
||||
);
|
||||
expect(rustImage).not.toBeNull();
|
||||
const rustImageRef = rustImage?.[1] ?? '';
|
||||
const rustImageDigest = rustImage?.[2] ?? '';
|
||||
const [major, minor] = channelValue.split('.');
|
||||
expect(rustImageRef).toBe(`rust:${major}.${minor}-bookworm`);
|
||||
|
||||
// 同一 Dockerfile 里可能出现在多个 LABEL 块中(后写的覆盖前者),必须同值,
|
||||
// 否则镜像真实版本会和脚本、文档的说法分叉。
|
||||
const labelVersions = [
|
||||
...imageDockerfile.matchAll(
|
||||
/org\.opencontainers\.image\.version="([0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+)"/gu,
|
||||
),
|
||||
].map((match) => match[1] ?? '');
|
||||
expect(labelVersions.length).toBeGreaterThan(0);
|
||||
expect(new Set(labelVersions).size).toBe(1);
|
||||
const stampMatch = (labelVersions.at(-1) ?? '').match(
|
||||
/^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/u,
|
||||
);
|
||||
expect(stampMatch).not.toBeNull();
|
||||
const imageTagStamp = `${stampMatch?.[1] ?? ''}${stampMatch?.[2] ?? ''}${
|
||||
stampMatch?.[3] ?? ''
|
||||
}.${stampMatch?.[4] ?? ''}`;
|
||||
expect(imageBuildScript).toContain(
|
||||
`genarrative/gitea-project-ci:${imageTagStamp}`,
|
||||
);
|
||||
|
||||
for (const doc of [deployContainerReadme, operationsDoc]) {
|
||||
expect(doc).toContain(rustImageDigest);
|
||||
expect(doc).toContain(`Rust \`${channelValue}\``);
|
||||
expect(doc).toContain(imageTagStamp);
|
||||
}
|
||||
|
||||
// runtime 校验按 rust-toolchain.toml 的 channel 逐字比对镜像内工具链名,
|
||||
// 所以基础镜像 digest 里的 RUST_VERSION 必须与 channel 完全相同。
|
||||
expect(imageCheckScript).toContain(
|
||||
'rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user