合并 master 并接入 DirectProject 新聊天架构

- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。
- 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。
- DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。
- App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。
- 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。
- 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。
- 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。
- 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
2026-09-22 16:45:29 +08:00
651 changed files with 78860 additions and 60952 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# 由能管理 CI 镜像的维护者运行;不得在 PR job 内提供 Docker API/发布权限。
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
base_ref="${1:?usage: build-gitea-rust-cache.sh <verified-base-image> <candidate-tag>}"
candidate_tag="${2:?candidate image tag is required}"
# 与实际 Gitea checkout 路径一致;Rust 对象 key 包含编译 cwd,不能随意换临时根。
workspace=/workspace/GenarrativeAI/Genarrative
[[ "${CI:-}" != true ]] || { echo 'Run on the trusted image builder, outside CI jobs.' >&2; exit 1; }
base_id="$(docker image inspect --format '{{.Id}}' "${base_ref}")"
[[ "${base_id}" =~ ^sha256:[a-f0-9]{64}$ ]]
# 删除容器内旧对象不能释放镜像底层;每次必须从不含对象快照的基础镜像重建。
docker run --rm --network none --read-only --cap-drop=ALL \
--security-opt=no-new-privileges --entrypoint /bin/bash "${base_id}" -c '
if [[ -e /opt/genarrative-ci/rust-cache ]]; then
echo "基础镜像已包含 Rust 对象缓存;请使用不含对象快照的原始 CI 镜像,禁止叠层。" >&2
exit 1
fi
'
bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${base_id}"
# 只归档远端 master 的确定提交;不复制当前工作区或本地凭据。
git -C "${repo_root}" fetch --no-tags origin refs/heads/master
source_commit="$(git -C "${repo_root}" rev-parse FETCH_HEAD^{commit})"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gitea-rust-cache.XXXXXX")"
container_id=''
cleanup() {
if [[ -n "${container_id}" ]]; then docker rm -f "${container_id}" >/dev/null; fi
rm -rf -- "${work_dir}"
}
trap cleanup EXIT
archive=sccache-v0.18.0-x86_64-unknown-linux-musl.tar.gz
curl --fail --location --retry 3 --connect-timeout 15 --max-time 180 \
"https://github.com/mozilla/sccache/releases/download/v0.18.0/${archive}" \
--output "${work_dir}/${archive}"
printf '45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89 %s\n' "${work_dir}/${archive}" | sha256sum --check
tar -xzf "${work_dir}/${archive}" --directory "${work_dir}"
mkdir "${work_dir}/snapshot"
cp "${work_dir}/sccache-v0.18.0-x86_64-unknown-linux-musl/sccache" "${work_dir}/snapshot/sccache"
printf '%s\n' "${source_commit}" > "${work_dir}/snapshot/source-commit.txt"
printf '%s\n' "${base_id}" > "${work_dir}/snapshot/base-image.txt"
printf '%s\n' "${workspace}" > "${work_dir}/snapshot/workspace.txt"
# 临时容器不挂载宿主目录/socket,不携带 Git/OSS/Jenkins 凭据,限制资源占用。
container_id="$(docker run --detach --cpus=4 --memory=12g --pids-limit=1024 \
--cap-drop=ALL --security-opt=no-new-privileges \
--entrypoint /bin/bash "${base_id}" -c 'sleep infinity')"
docker exec "${container_id}" mkdir -p "${workspace}" /opt/genarrative-ci/rust-cache/objects
git -C "${repo_root}" archive "${source_commit}" | docker cp - "${container_id}:${workspace}"
docker cp "${work_dir}/snapshot/." "${container_id}:/opt/genarrative-ci/rust-cache/"
docker cp "${repo_root}/scripts/ci-rust-cache.sh" "${container_id}:/tmp/ci-rust-cache.sh"
docker exec --interactive --workdir "${workspace}" "${container_id}" bash -s <<'WARM'
set -euo pipefail
rustc -vV > /opt/genarrative-ci/rust-cache/rustc.txt
export GITHUB_ENV=/tmp/rust-cache.env CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=4 CI=true
# sccache 对 CARGO_*(除 jobserver/jobs 等特例)参与 hash,必须与 workflow 对齐。
export CARGO_HTTP_MULTIPLEXING=false CARGO_NET_RETRY=10 CARGO_TERM_COLOR=always
bash /tmp/ci-rust-cache.sh prepare
set -a
source "${GITHUB_ENV}"
set +a
test -n "${RUSTC_WRAPPER}"
trap 'bash /tmp/ci-rust-cache.sh report' EXIT
# 与 CI 的工作目录、features 和目标逐项对齐,只编译,不运行测试/应用。
echo '[rust-cache] warming backend tests and checks'
cargo test --locked --workspace --exclude spacetime-module --no-fail-fast \
--manifest-path server-rs/Cargo.toml --no-run
cargo test --locked -p spacetime-module --no-fail-fast \
--manifest-path server-rs/Cargo.toml --no-run
cargo check --locked -p api-server --all-targets --manifest-path server-rs/Cargo.toml
cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
echo '[rust-cache] warming AGC standalone, shared and plugin crates'
for manifest in \
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; do
# 与 CI 一样,这些 crate 没有已提交的独立 Cargo.lock。
cargo test --manifest-path "${manifest}" --no-run
done
cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml --no-run
cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml --no-run
for editor in unity godot; do
cargo test --locked --manifest-path "plugins/agc-${editor}-editor/native/${editor}-editor-bridge/Cargo.toml" --no-run
done
echo '[rust-cache] warming desktop tests and AGC prompt contracts'
cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \
--test runtime_prompt_bundle_build --test prompt_source_boundaries --no-run
# Cargo 的 fresh 判断不保证不同 cwd 都经过 sccache;先清掉临时容器中的 target。
cargo clean --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
echo '[rust-cache] warming AGC Rust lanes'
(
cd apps/ai-game-creator-shell/src-tauri
cargo test --locked --manifest-path Cargo.toml \
--bin genarrative-ai-game-creator-shell --no-run
)
cargo clean --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
echo '[rust-cache] warming AGC agent-run smoke executable'
(
cd apps/ai-game-creator-shell
cargo build --manifest-path src-tauri/Cargo.toml
)
du -sh /opt/genarrative-ci/rust-cache/objects
WARM
docker cp "${container_id}:/opt/genarrative-ci/rust-cache/." "${work_dir}/snapshot/"
docker rm -f "${container_id}" >/dev/null
container_id=''
# 从原基础镜像重新组装,只 COPY 对象快照;不 commit 含源码/target 的预热容器。
cat > "${work_dir}/Dockerfile" <<EOF
FROM ${base_id}
COPY snapshot/ /opt/genarrative-ci/rust-cache/
LABEL world.genarrative.ci.rust-cache-source="${source_commit}"
LABEL world.genarrative.ci.rust-cache-base="${base_id}"
EOF
printf '**\n!Dockerfile\n!snapshot/\n!snapshot/**\n' > "${work_dir}/.dockerignore"
docker build --pull=false --tag "${candidate_tag}" "${work_dir}"
bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${candidate_tag}"
printf 'snapshot_source=%s\ncandidate_image=%s\n' "${source_commit}" "$(docker image inspect --format '{{.Id}}' "${candidate_tag}")"
echo 'Candidate only: the runner configuration and running jobs have not been changed.'
+174 -2
View File
@@ -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,
},
},
);
+13
View File
@@ -28,6 +28,19 @@ bash -n /usr/local/bin/genarrative-gitea-checkout
test -d /root/.npm/_cacache
test -d /usr/local/cargo/registry/cache
# 对象快照是可选镜像层;构建/装载前验证,运行时故障由 prepare 回退直接 rustc。
rust_cache_root=/opt/genarrative-ci/rust-cache
if [[ -d "${rust_cache_root}" && "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" != 1 ]]; then
test "$("${rust_cache_root}/sccache" --version)" = 'sccache 0.18.0'
rustc -vV | cmp -s - "${rust_cache_root}/rustc.txt"
rg -q '^[a-f0-9]{40}$' "${rust_cache_root}/source-commit.txt"
rg -q '^sha256:[a-f0-9]{64}$' "${rust_cache_root}/base-image.txt"
test "$(cat "${rust_cache_root}/workspace.txt")" = '/workspace/GenarrativeAI/Genarrative'
test -n "$(find "${rust_cache_root}/objects" -type f -print -quit)"
test ! -e /workspace/GenarrativeAI/Genarrative
printf 'rust_object_snapshot=verified\n'
fi
verify_cache_lock() {
local cache_name="$1"
local expected_sha256="$2"
+4 -93
View File
@@ -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-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rust-cratesAGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
// 无锁独立 crate),只需 server-rs 侧的依赖预热。
// - agc-rust-shard-1..4AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,一片一个
// 分组(CI 里就是一个 job),片内仍保持 `--test-threads=1`;每个分片分组都会做一次
// - agc-rust-shard-1..4AGC 壳自身的 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',
@@ -2623,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',
@@ -2760,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(',
);
+141 -4
View File
@@ -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 还必须授权手动发布管线读取总号,否则用户触发的手动发布会停在 copyArtifactsSYSTEM 定时构建不受影响)。',
},
{
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-fullfiles-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()', '必须禁止并发触发,避免同一版本重复触发下游'],
@@ -7723,6 +7848,18 @@ for (const [snippet, reason] of [
"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;
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# 仅消费镜像内的可信快照;所有写入留在当前容器的可写层。
set -euo pipefail
cache_root="${GENARRATIVE_CI_RUST_CACHE_ROOT:-/opt/genarrative-ci/rust-cache}"
cache_binary="${cache_root}/sccache"
state="${GENARRATIVE_CI_RUST_CACHE_STATE:-}"
configure_local_cache() {
# 不继承开发机/其它流水线的 OSS、S3、GHA 或 daemon 配置。
local variable
for variable in ${!SCCACHE_@}; do unset "${variable}"; done
export SCCACHE_CONF="${state}/config"
export SCCACHE_DIR="${cache_root}/objects"
export SCCACHE_CACHE_SIZE=4G
export SCCACHE_SERVER_UDS="${state}/server.sock"
# 单个不可缓存的链接/测试阶段可能超过一分钟;保持统计直到 report 显式停止。
export SCCACHE_IDLE_TIMEOUT=0
export SCCACHE_IGNORE_SERVER_IO_ERROR=1
}
case "${1:-}" in
prepare)
: "${GITHUB_ENV:?GITHUB_ENV is required}"
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\n' >> "${GITHUB_ENV}"
fallback() { printf '[rust-cache] mode=direct reason=%s\n' "$1"; exit 0; }
[[ -x "${cache_binary}" && -d "${cache_root}/objects" && -f "${cache_root}/rustc.txt" && -f "${cache_root}/source-commit.txt" && -f "${cache_root}/workspace.txt" ]] \
|| fallback snapshot-unavailable
if ! rustc -vV | cmp -s - "${cache_root}/rustc.txt"; then
fallback toolchain-mismatch
fi
if [[ "$(pwd -P)" != "$(cat "${cache_root}/workspace.txt")" ]]; then
fallback workspace-mismatch
fi
# 使用短 Unix socket 路径,避免共享固定端口或超出 sockaddr_un 长度。
state="$(mktemp -d /tmp/ci-rust-cache.XXXXXX)"
printf 'server_startup_timeout_ms = 5000\n' > "${state}/config"
configure_local_cache
# 探测必须暴露 daemon 故障;只有正式编译允许 sccache 的 IO 回退。
unset SCCACHE_IGNORE_SERVER_IO_ERROR
if ! timeout --kill-after=2 15 "${cache_binary}" "$(command -v rustc)" -vV > "${state}/probe.log" 2>&1; then
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
rm -rf -- "${state}"
fallback wrapper-probe-failed
fi
script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
# wrapper 路径也参与 Rust cache key;固定容器内路径,隔离由 job 容器保证。
wrapper_path="${cache_root}/rustc-wrapper"
printf '#!/usr/bin/env bash\nexec bash %q "$@"\n' "${script_path}" > "${wrapper_path}"
chmod 700 "${wrapper_path}"
{
printf 'GENARRATIVE_CI_RUST_CACHE_ROOT=%s\n' "${cache_root}"
printf 'GENARRATIVE_CI_RUST_CACHE_STATE=%s\n' "${state}"
printf 'RUSTC_WRAPPER=%s\nCARGO_BUILD_RUSTC_WRAPPER=%s\n' "${wrapper_path}" "${wrapper_path}"
} >> "${GITHUB_ENV}"
printf '[rust-cache] mode=sccache snapshot=%s\n' "$(cat "${cache_root}/source-commit.txt")"
;;
report)
if [[ -n "${state}" && -d "${state}" ]]; then
configure_local_cache
timeout --kill-after=2 5 "${cache_binary}" --show-stats || true
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
rm -rf -- "${state}"
else
printf '[rust-cache] mode=direct\n'
fi
;;
*)
# Cargo wrapper 协议:第一个参数是真实 rustc。失去缓存状态时仍执行原编译。
if [[ -z "${state}" || ! -f "${state}/config" || -f "${state}/disabled" || ! -x "${cache_binary}" ]]; then
exec "$@"
fi
configure_local_cache
status=0
"${cache_binary}" "$@" || status=$?
if [[ "${status}" == 2 ]]; then
# 固定 sccache 版本的自身错误码为 2;实际 rustc 失败通常为 1/101。
# 只重试这次编译,不重跑 Cargo 或测试,真实编译错误仍按 rustc 状态返回。
echo '[rust-cache] cache infrastructure failed; compiling directly' >&2
# 后续 crate 直接编译,避免坏 daemon 让每个 crate 都支付一次启动超时。
: > "${state}/disabled"
exec "$@"
fi
exit "${status}"
;;
esac
+217
View File
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { test } from 'node:test';
const script = resolve('scripts/ci-rust-cache.sh');
const linuxTest = process.platform === 'linux' ? test : test.skip;
function fixture(t) {
const directory = mkdtempSync(join(tmpdir(), 'ci-rust-cache-test-'));
const root = join(directory, 'snapshot');
const bin = join(directory, 'bin');
mkdirSync(join(root, 'objects'), { recursive: true });
mkdirSync(bin);
const envFile = join(directory, 'github-env');
writeFileSync(envFile, '');
writeFileSync(join(root, 'rustc.txt'), 'fixture rustc identity\n');
writeFileSync(join(root, 'source-commit.txt'), 'trusted-master-commit\n');
writeFileSync(join(root, 'workspace.txt'), `${process.cwd()}\n`);
writeFileSync(
join(bin, 'rustc'),
'#!/bin/bash\necho "fixture rustc identity"\n',
{ mode: 0o755 },
);
writeFileSync(
join(root, 'sccache'),
`#!/bin/bash
set -eu
case "$1" in
--stop-server|--show-stats) exit 0 ;;
esac
if [[ "$*" == *-vV ]]; then
[[ "\${PROBE_FAILURE:-}" != 1 ]] || exit 1
exec "$@"
fi
printf '%s\\n' "\${SCCACHE_OSS_BUCKET-unset}" "\${SCCACHE_CONF}" "\${SCCACHE_DIR}" "\${SCCACHE_SERVER_UDS}" "\${SCCACHE_IGNORE_SERVER_IO_ERROR}" "\${SCCACHE_IDLE_TIMEOUT}" >> "\${TRACE}"
[[ "\${CACHE_FAILURE:-}" != 2 ]] || exit 2
exec "$@"
`,
{ mode: 0o755 },
);
const env = {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
GITHUB_ENV: envFile,
GENARRATIVE_CI_RUST_CACHE_ROOT: root,
GENARRATIVE_CI_RUST_CACHE_STATE: '',
RUSTC_WRAPPER: 'bad-inherited-wrapper',
CARGO_BUILD_RUSTC_WRAPPER: 'bad-inherited-wrapper',
SCCACHE_OSS_BUCKET: 'must-not-use-publishing-cache',
TRACE: join(directory, 'trace'),
};
function run(args, extraEnv = {}) {
return spawnSync('bash', [script, ...args], {
env: { ...env, ...extraEnv },
encoding: 'utf8',
});
}
function preparedEnv() {
return Object.fromEntries(
readFileSync(envFile, 'utf8')
.trim()
.split('\n')
.map((line) => {
const separator = line.indexOf('=');
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
}
t.after(() => {
run(['report'], preparedEnv());
rmSync(directory, { recursive: true, force: true });
});
return { directory, root, env, run, preparedEnv };
}
linuxTest(
'missing snapshot and toolchain mismatch retain direct rustc',
(t) => {
const f = fixture(t);
const missing = f.run(['prepare'], {
GENARRATIVE_CI_RUST_CACHE_ROOT: join(f.directory, 'absent'),
});
assert.equal(missing.status, 0, missing.stderr);
assert.match(missing.stdout, /reason=snapshot-unavailable/);
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
writeFileSync(join(f.root, 'rustc.txt'), 'different compiler\n');
const mismatch = f.run(['prepare']);
assert.equal(mismatch.status, 0, mismatch.stderr);
assert.match(mismatch.stdout, /reason=toolchain-mismatch/);
assert.equal(f.preparedEnv().CARGO_BUILD_RUSTC_WRAPPER, '');
writeFileSync(join(f.root, 'rustc.txt'), 'fixture rustc identity\n');
writeFileSync(join(f.root, 'workspace.txt'), '/different-checkout\n');
const moved = f.run(['prepare']);
assert.equal(moved.status, 0, moved.stderr);
assert.match(moved.stdout, /reason=workspace-mismatch/);
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
},
);
linuxTest(
'preparations keep the compiler wrapper path stable with separate daemons',
(t) => {
const f = fixture(t);
assert.equal(f.run(['prepare']).status, 0);
const first = f.preparedEnv();
f.run(['report'], first);
assert.equal(f.run(['prepare']).status, 0);
const second = f.preparedEnv();
assert.equal(
first.CARGO_BUILD_RUSTC_WRAPPER,
second.CARGO_BUILD_RUSTC_WRAPPER,
);
assert.equal(first.RUSTC_WRAPPER, second.RUSTC_WRAPPER);
assert.notEqual(
first.GENARRATIVE_CI_RUST_CACHE_STATE,
second.GENARRATIVE_CI_RUST_CACHE_STATE,
);
},
);
linuxTest('failed wrapper probe falls back without enabling the cache', (t) => {
const f = fixture(t);
const result = f.run(['prepare'], { PROBE_FAILURE: '1' });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /reason=wrapper-probe-failed/);
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
assert.equal(f.preparedEnv().GENARRATIVE_CI_RUST_CACHE_STATE, '');
});
linuxTest(
'wrapper isolates remote settings and preserves compiler failures without retry',
(t) => {
const f = fixture(t);
const prepared = f.run(['prepare']);
assert.equal(prepared.status, 0, prepared.stderr);
const cachedEnv = f.preparedEnv();
const result = spawnSync(
cachedEnv.RUSTC_WRAPPER,
['/bin/bash', '-c', 'exit 42'],
{
env: { ...f.env, ...cachedEnv },
encoding: 'utf8',
},
);
assert.equal(result.status, 42, result.stderr);
const trace = readFileSync(f.env.TRACE, 'utf8').trim().split('\n');
assert.deepEqual(trace, [
'unset',
`${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/config`,
`${f.root}/objects`,
`${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/server.sock`,
'1',
'0',
]);
},
);
linuxTest(
'job copies use different cache directories and daemon sockets',
(t) => {
const first = fixture(t);
const second = fixture(t);
for (const f of [first, second]) {
const prepared = f.run(['prepare']);
assert.equal(prepared.status, 0, prepared.stderr);
const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, ['/bin/true'], {
env: { ...f.env, ...f.preparedEnv() },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
}
const a = readFileSync(first.env.TRACE, 'utf8').split('\n');
const b = readFileSync(second.env.TRACE, 'utf8').split('\n');
assert.notEqual(a[2], b[2]);
assert.notEqual(a[3], b[3]);
},
);
linuxTest(
'sccache infrastructure failure invokes rustc and preserves its result',
(t) => {
const f = fixture(t);
const prepared = f.run(['prepare']);
assert.equal(prepared.status, 0, prepared.stderr);
for (const [compiler, status] of [
['/bin/false', 1],
['/bin/true', 0],
]) {
const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, [compiler], {
env: { ...f.env, ...f.preparedEnv(), CACHE_FAILURE: '2' },
encoding: 'utf8',
});
assert.equal(result.status, status, result.stderr);
if (status === 1)
assert.match(result.stderr, /cache infrastructure failed/);
else assert.equal(result.stderr, '');
}
assert.equal(
readFileSync(f.env.TRACE, 'utf8').trim().split('\n').length,
6,
);
},
);
linuxTest('lost local cache state still invokes the real compiler', (t) => {
const f = fixture(t);
assert.equal(f.run(['/bin/bash', '-c', 'exit 43']).status, 43);
});
File diff suppressed because it is too large Load Diff
+215 -23
View File
@@ -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(默认 1archive 空间不足自动降级 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}")"
+8 -3
View 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:20260920.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
} \
+37 -4
View File
@@ -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-fullfiles-history,当前值: ${DATABASE_BACKUP_PROFILE}" >&2
echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-fullfiles-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
+160 -25
View File
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { createVitest } from 'vitest/node';
const workflow = readFileSync(
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
@@ -15,6 +16,10 @@ const imageCheckScript = readFileSync(
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
'utf8',
);
const rustCacheBuildScript = readFileSync(
resolve(process.cwd(), 'scripts/build-gitea-rust-cache.sh'),
'utf8',
);
const npmCiRetryScript = readFileSync(
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
'utf8',
@@ -56,10 +61,8 @@ const jobNames = [
'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;
@@ -69,10 +72,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',
];
@@ -141,6 +142,80 @@ function backendStepIndex(stepName: string) {
}
describe('project CI workflow', () => {
it('uses isolated compilation caching for every Rust test job', () => {
const firstRustSteps = {
'ai-game-creator-shell-rust-lane-1':
'Run AI game creator shell Rust shard 1/4',
'ai-game-creator-shell-rust-lane-2':
'Run AI game creator shell Rust shard 3/4',
'ai-game-creator-shell-rust-smoke':
'Run AI game creator shell agent-run smoke',
'ai-game-creator-shell-rust-crates':
'Run AI game creator shell shared crate gates',
'backend-tests': 'Run server-rs workspace tests',
'native-shell-tests': 'Run native shell gates',
};
for (const job of jobNames) {
const section = jobSection(job);
if (!(job in firstRustSteps)) {
expect(section).not.toContain('ci-rust-cache.sh');
continue;
}
const firstRustStep = firstRustSteps[job as keyof typeof firstRustSteps];
const prepare = section.indexOf('bash scripts/ci-rust-cache.sh prepare');
expect(prepare).toBeGreaterThan(
section.indexOf('Checkout full history from Gitea'),
);
expect(prepare).toBeLessThan(section.indexOf(`- name: ${firstRustStep}`));
expect(
stepSection(job, 'Report isolated Rust compilation cache'),
).toContain('if: always()');
}
// 缓存自身的行为测试只执行一次,避免随 job 数量重复运行。
expect(
workflow.match(/node --test scripts\/ci-rust-cache.test.mjs/g),
).toHaveLength(1);
const releaseStep = stepSection(
'native-shell-tests',
'Run native shell release build smoke',
);
expect(releaseStep).toContain("RUSTC_WRAPPER: ''");
expect(releaseStep).toContain("CARGO_BUILD_RUSTC_WRAPPER: ''");
expect(workflow).toContain("CARGO_INCREMENTAL: '0'");
expect(workflow).toContain("RUSTC_WRAPPER: ''");
for (const [, name, value] of workflow
.slice(0, workflow.indexOf('\njobs:'))
.matchAll(/^ {2}(CARGO_[A-Z0-9_]+): '?([^'\n]*)'?$/gm)) {
// wrapper 在 prepare 中选择,其余 Cargo 环境必须和预热一致。
if (name === 'CARGO_BUILD_RUSTC_WRAPPER') continue;
expect(rustCacheBuildScript).toContain(`${name}=${value}`);
}
});
it('warms backend targets without merging the SpacetimeDB feature boundary', () => {
const warmCommands = rustCacheBuildScript
.replace(/\\\r?\n/g, '')
.replace(/\s+/g, ' ');
for (const [, command] of jobSection('backend-tests').matchAll(
/run: (cargo [^\n]+)/g,
)) {
expect(warmCommands).toContain(command.trim());
}
expect(warmCommands).toContain(
'cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml --no-run',
);
expect(warmCommands).toContain(
'cargo test --locked -p spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml --no-run',
);
expect(warmCommands).toContain('cd apps/ai-game-creator-shell/src-tauri');
expect(warmCommands).toContain(
'cd apps/ai-game-creator-shell cargo build --manifest-path src-tauri/Cargo.toml',
);
expect(warmCommands).toContain(
'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run',
);
});
it('runs for master pushes, pull requests, and manual dispatch only', () => {
expect(workflow).toMatch(
/on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u,
@@ -182,8 +257,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}',
@@ -263,6 +346,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}"',
);
@@ -377,7 +476,10 @@ describe('project CI workflow', () => {
it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => {
const frontendJob = jobSection('frontend-tests');
expect(frontendJob).toContain('run: npm run test');
expect(frontendJob).toMatch(/^ {8}run: npm run test:ci:frontend$/mu);
expect(rootPackageJson.scripts?.['test:ci:frontend']).toBe(
'vitest run --config vitest.frontend-ci.config.ts',
);
expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test');
expect(frontendJob).toContain(
'run: npm run check:production-health-patrol',
@@ -399,6 +501,39 @@ describe('project CI workflow', () => {
expect(nativeJob).toContain('cargo fetch --locked');
});
it('partitions frontend and AGC test files without gaps or duplicates', async () => {
expect(rootPackageJson.scripts?.test).toBe('vitest run');
const full = await createVitest('test', {
config: resolve('vitest.config.ts'),
watch: false,
});
try {
const allFiles = (await full.globTestFiles()).map(([, file]) => file);
const agcFiles = (
await full.globTestFiles(['apps/ai-game-creator-shell/tests'])
).map(([, file]) => file);
const frontend = await createVitest('test', {
config: resolve('vitest.frontend-ci.config.ts'),
watch: false,
});
try {
const frontendFiles = (await frontend.globTestFiles()).map(
([, file]) => file,
);
expect(agcFiles.length).toBeGreaterThan(0);
expect(frontendFiles.length).toBeGreaterThan(0);
expect(frontendFiles.filter((file) => agcFiles.includes(file))).toEqual(
[],
);
expect([...frontendFiles, ...agcFiles].sort()).toEqual(allFiles.sort());
} finally {
await frontend.close();
}
} finally {
await full.close();
}
}, 30_000);
it('runs every native shell gate group exactly once across the split jobs', () => {
for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) {
expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe(
@@ -441,26 +576,26 @@ describe('project CI workflow', () => {
'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}'`);