Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2f85c089 | |||
| 4380d453d7 | |||
| f502829fde | |||
| a56790eaa6 | |||
| 6e1fbac5d2 | |||
| b93d15e46e | |||
| 57a72f652c | |||
| 55801deb38 | |||
| a46302846a | |||
| 1eaa51b72b | |||
| 05a5ea4d85 | |||
| 3103a6e632 | |||
| b417144baf | |||
| fd0c1007ad |
@@ -2641,48 +2641,6 @@ const databaseTableLabelMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单',
|
||||
profile_feedback_submission: '反馈提交',
|
||||
profile_save_archive: '存档记录',
|
||||
story_session: '剧情会话',
|
||||
story_event: '剧情事件',
|
||||
npc_state: 'NPC 状态',
|
||||
inventory_slot: '背包槽位',
|
||||
battle_state: '战斗状态',
|
||||
treasure_record: '宝藏记录',
|
||||
quest_record: '任务记录',
|
||||
quest_log: '任务日志',
|
||||
player_progression: '玩家进度',
|
||||
chapter_progression: '章节进度',
|
||||
custom_world_profile: '自定义世界档案',
|
||||
custom_world_session: '自定义世界会话',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作',
|
||||
custom_world_draft_card: '自定义世界草稿卡片',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目',
|
||||
puzzle_agent_session: '拼图 Agent 会话',
|
||||
puzzle_agent_message: '拼图 Agent 消息',
|
||||
puzzle_work_profile: '拼图作品档案',
|
||||
puzzle_event: '拼图事件',
|
||||
puzzle_runtime_run: '拼图运行记录',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息',
|
||||
match3d_work_profile: '抓大鹅作品档案',
|
||||
match3d_runtime_run: '抓大鹅运行记录',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息',
|
||||
square_hole_work_profile: '方洞挑战作品档案',
|
||||
square_hole_runtime_run: '方洞挑战运行记录',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息',
|
||||
visual_novel_work_profile: '视觉小说作品档案',
|
||||
visual_novel_runtime_run: '视觉小说运行记录',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目',
|
||||
visual_novel_runtime_event: '视觉小说运行事件',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
|
||||
big_fish_event: '大鱼吃小鱼事件',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录',
|
||||
asset_object: '资产对象',
|
||||
asset_entity_binding: '资产实体绑定',
|
||||
asset_event: '资产事件',
|
||||
@@ -2724,48 +2682,6 @@ const databaseTableDescriptionMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单表',
|
||||
profile_feedback_submission: '反馈提交记录表',
|
||||
profile_save_archive: '用户存档记录表',
|
||||
story_session: '剧情会话表',
|
||||
story_event: '剧情事件表',
|
||||
npc_state: 'NPC 状态表',
|
||||
inventory_slot: '背包槽位表',
|
||||
battle_state: '战斗状态表',
|
||||
treasure_record: '宝藏记录表',
|
||||
quest_record: '任务记录表',
|
||||
quest_log: '任务日志表',
|
||||
player_progression: '玩家进度表',
|
||||
chapter_progression: '章节进度表',
|
||||
custom_world_profile: '自定义世界档案表',
|
||||
custom_world_session: '自定义世界会话表',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话表',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息表',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作表',
|
||||
custom_world_draft_card: '自定义世界草稿卡片表',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目表',
|
||||
puzzle_agent_session: '拼图 Agent 会话表',
|
||||
puzzle_agent_message: '拼图 Agent 消息表',
|
||||
puzzle_work_profile: '拼图作品档案表',
|
||||
puzzle_event: '拼图事件表',
|
||||
puzzle_runtime_run: '拼图运行记录表',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目表',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话表',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息表',
|
||||
match3d_work_profile: '抓大鹅作品档案表',
|
||||
match3d_runtime_run: '抓大鹅运行记录表',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话表',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息表',
|
||||
square_hole_work_profile: '方洞挑战作品档案表',
|
||||
square_hole_runtime_run: '方洞挑战运行记录表',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话表',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息表',
|
||||
visual_novel_work_profile: '视觉小说作品档案表',
|
||||
visual_novel_runtime_run: '视觉小说运行记录表',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目表',
|
||||
visual_novel_runtime_event: '视觉小说运行事件表',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话表',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
|
||||
big_fish_event: '大鱼吃小鱼事件表',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
|
||||
asset_object: '资产对象表',
|
||||
asset_entity_binding: '资产实体绑定表',
|
||||
asset_event: '资产事件表',
|
||||
|
||||
@@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('publish');
|
||||
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
|
||||
).toContain('游戏发布入口灰度');
|
||||
});
|
||||
|
||||
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
gates: [
|
||||
...configResponse.gates,
|
||||
{
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
updatedAt: '2026-09-22T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'game-distribution',
|
||||
]);
|
||||
fireEvent.click(screen.getByLabelText('启用'));
|
||||
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
||||
target: { value: '20' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
||||
target: { value: 'user-internal' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: '游戏发布入口灰度' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled: true,
|
||||
rolloutPercent: 20,
|
||||
allowUserIds: ['user-internal'],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: '游戏发布入口灰度',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('未创建的预设开关在后台可见并可一键配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = await screen.findByText('game-distribution:publish');
|
||||
expect(row).not.toBeNull();
|
||||
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
|
||||
const configureButton = row.closest('tr')?.querySelector('button');
|
||||
expect(configureButton).not.toBeNull();
|
||||
await user.click(configureButton!);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -28,6 +28,7 @@ interface GateTargetOption {
|
||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||
'image-editor': '画布',
|
||||
agc: '客户端',
|
||||
'game-distribution': '游戏分发',
|
||||
};
|
||||
|
||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
@@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
label: 'Agent 侧边栏',
|
||||
description: '画布 Agent 入口灰度',
|
||||
},
|
||||
{
|
||||
prefix: 'game-distribution',
|
||||
suffix: 'publish',
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminGrayReleaseConfigPage({
|
||||
@@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
|
||||
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
|
||||
(option) => !gates.some((gate) => gate.gateKey === option.key),
|
||||
);
|
||||
|
||||
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
|
||||
return {
|
||||
gateKey: gateKey.trim(),
|
||||
@@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>可配置开关</h3>
|
||||
<span>{unconfiguredGateTargets.length}</span>
|
||||
</div>
|
||||
{unconfiguredGateTargets.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gate</th>
|
||||
<th>说明</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unconfiguredGateTargets.map((option) => (
|
||||
<tr key={option.key}>
|
||||
<td>
|
||||
{option.key}
|
||||
<small>
|
||||
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
|
||||
{option.label}
|
||||
</small>
|
||||
</td>
|
||||
<td>{option.description}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => applyGateTarget(option)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '预设开关都已创建'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
@@ -5974,15 +5974,19 @@ pub(crate) fn create_local_project_checkpoint(
|
||||
create_local_project_checkpoint_at(root)
|
||||
}
|
||||
|
||||
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
|
||||
///
|
||||
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
|
||||
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
|
||||
#[tauri::command]
|
||||
pub(crate) fn export_local_project_package(
|
||||
pub(crate) async fn export_local_project_package(
|
||||
project_path: String,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "project.export_package")?;
|
||||
let _lock = acquire_project_write_lock(root, "project.export_package")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
export_local_project_package_at(root)
|
||||
export_local_project_package_for_publish_at(root).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -136,6 +136,159 @@ pub(crate) fn export_local_project_package_at(
|
||||
///
|
||||
/// The caller receives the package bytes and a deterministic file manifest, but
|
||||
/// never receives a filesystem path that it could accidentally send to the API.
|
||||
/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤,
|
||||
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
|
||||
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
|
||||
|
||||
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
|
||||
///
|
||||
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
|
||||
/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。
|
||||
pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result<Option<&'static str>, String> {
|
||||
for cwd in [".", "game"] {
|
||||
let package_root = if cwd == "." {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
resolve_local_project_path(root, cwd)?
|
||||
};
|
||||
let package_path = package_root.join("package.json");
|
||||
let metadata = match fs::symlink_metadata(&package_path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(&package_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) else {
|
||||
continue;
|
||||
};
|
||||
let declared = package
|
||||
.get("scripts")
|
||||
.and_then(|scripts| scripts.get("build"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if declared.is_some() {
|
||||
return Ok(Some(cwd));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。
|
||||
pub(crate) fn read_publish_build_command(
|
||||
root: &Path,
|
||||
cwd_relative: &str,
|
||||
) -> Result<String, String> {
|
||||
let package_root = if cwd_relative == "." {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
resolve_local_project_path(root, cwd_relative)?
|
||||
};
|
||||
let package_path = package_root.join("package.json");
|
||||
let content = fs::read_to_string(&package_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 package.json 失败:{}: {error}",
|
||||
package_path.display()
|
||||
)
|
||||
})?;
|
||||
let package: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
|
||||
package
|
||||
.get("scripts")
|
||||
.and_then(|scripts| scripts.get("build"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "package.json 未定义 build 脚本".to_string())
|
||||
}
|
||||
|
||||
/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。
|
||||
fn publish_build_failure_tail(output: &str) -> String {
|
||||
const MAX_CHARS: usize = 2_000;
|
||||
let trimmed = output.trim();
|
||||
let chars = trimmed.chars().count();
|
||||
if chars <= MAX_CHARS {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::<String>();
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct PublishBuildPlan {
|
||||
pub(crate) cwd_relative: &'static str,
|
||||
pub(crate) command: String,
|
||||
/// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。
|
||||
pub(crate) needs_dependency_install: bool,
|
||||
}
|
||||
|
||||
/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。
|
||||
pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result<PublishBuildPlan, String> {
|
||||
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
|
||||
return Err(
|
||||
"项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let command = read_publish_build_command(root, cwd_relative)?;
|
||||
let needs_dependency_install =
|
||||
cwd_relative == "game" && !root.join("game").join("node_modules").is_dir();
|
||||
Ok(PublishBuildPlan {
|
||||
cwd_relative,
|
||||
command,
|
||||
needs_dependency_install,
|
||||
})
|
||||
}
|
||||
|
||||
/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。
|
||||
///
|
||||
/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包;
|
||||
/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败
|
||||
/// 返回带日志尾部的可操作错误,不回传本地路径。
|
||||
pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
if validate_project_game_entry(root).is_ok() {
|
||||
return export_local_project_package_at(root);
|
||||
}
|
||||
let plan = resolve_publish_build_plan(root)?;
|
||||
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
|
||||
// 否则作者要点两次(先 bootstrap 再发布)。
|
||||
if plan.needs_dependency_install {
|
||||
let bootstrap =
|
||||
crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?;
|
||||
if bootstrap.status != "completed" {
|
||||
return Err(format!(
|
||||
"安装 game 依赖失败(npm install 未通过):\n{}",
|
||||
publish_build_failure_tail(&bootstrap.output)
|
||||
));
|
||||
}
|
||||
}
|
||||
let built = crate::project::verification::run_project_verification_with_commit_at(
|
||||
root,
|
||||
"build",
|
||||
&plan.command,
|
||||
PUBLISH_BUILD_TIMEOUT_SECONDS,
|
||||
plan.cwd_relative,
|
||||
|| Ok(()),
|
||||
)
|
||||
.await?;
|
||||
if built.status != "completed" {
|
||||
return Err(format!(
|
||||
"构建可玩版本失败(npm run build 未通过):\n{}",
|
||||
publish_build_failure_tail(&built.output)
|
||||
));
|
||||
}
|
||||
validate_project_game_entry(root)
|
||||
.map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?;
|
||||
export_local_project_package_at(root)
|
||||
}
|
||||
|
||||
pub(crate) fn read_local_project_export_package_at(
|
||||
root: &Path,
|
||||
package_relative_path: &str,
|
||||
|
||||
@@ -3935,6 +3935,165 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-plan-game", "Phaser 工程").expect("project init");
|
||||
|
||||
// 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。
|
||||
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert_eq!(plan.cwd_relative, "game");
|
||||
assert_eq!(plan.command, "vite build");
|
||||
assert!(
|
||||
plan.needs_dependency_install,
|
||||
"缺少 game/node_modules 时应先装依赖"
|
||||
);
|
||||
|
||||
fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules");
|
||||
let installed = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert!(
|
||||
!installed.needs_dependency_install,
|
||||
"已有依赖时不应重复 install"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_build_plan_falls_back_to_root_npm_build() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-plan-root", "根工程构建").expect("project init");
|
||||
// 去掉 game 子工程的 build,改用项目根 npm 工程构建。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "plan-root-game",
|
||||
"private": true,
|
||||
"scripts": { "check": "node -e \"process.exit(0)\"" }
|
||||
}))
|
||||
.expect("serialize game package json"),
|
||||
)
|
||||
.expect("write game package json");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "plan-root-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-root.mjs" }
|
||||
}))
|
||||
.expect("serialize root package json"),
|
||||
)
|
||||
.expect("write root package json");
|
||||
|
||||
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
|
||||
assert_eq!(plan.cwd_relative, ".");
|
||||
assert_eq!(plan.command, "node build-root.mjs");
|
||||
assert!(!plan.needs_dependency_install);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_runs_project_build_before_packaging() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-auto-build", "自动构建发布项目")
|
||||
.expect("project init");
|
||||
// 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-auto-build-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-publish.mjs" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
fs::write(
|
||||
root.join("build-publish.mjs"),
|
||||
r#"import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
mkdirSync('dist/assets', { recursive: true });
|
||||
writeFileSync('dist/index.html', '<!doctype html><html><head><meta charset="utf-8"><title>Auto Build</title><script src="assets/app.js"></script></head><body><h1>AUTO-BUILD</h1></body></html>');
|
||||
writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";');
|
||||
"#,
|
||||
)
|
||||
.expect("write build script");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("发布前构建并导出");
|
||||
|
||||
assert!(root.join("dist/index.html").is_file());
|
||||
assert!(root.join("dist/assets/app.js").is_file());
|
||||
assert!(result
|
||||
.package_relative_path
|
||||
.starts_with("exports/playtest-package-"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
log.contains("project.verify build"),
|
||||
"发布前应记录一次 project.verify build:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-publish-skip", "已构建发布项目")
|
||||
.expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("已有可玩入口时直接导出");
|
||||
|
||||
assert!(result.package_relative_path.ends_with(".zip"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
"已有可玩入口时不应触发构建:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
|
||||
.expect("project init");
|
||||
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-no-entry-fixture",
|
||||
"private": true,
|
||||
"scripts": { "check": "node -e \"process.exit(0)\"" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
|
||||
let error = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect_err("缺少入口且没有 build 脚本时必须失败关闭");
|
||||
|
||||
assert!(
|
||||
error.contains("还没有可玩入口"),
|
||||
"错误应说明缺少可玩入口:{error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("build 脚本"),
|
||||
"错误应指向 build 脚本:{error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -1206,16 +1206,27 @@ export function App({
|
||||
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
|
||||
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
*
|
||||
* 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus
|
||||
* 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。
|
||||
*/
|
||||
function announcePublishMessage(message: string) {
|
||||
setWorkspaceStatus(message);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
}
|
||||
|
||||
async function requestGamePublish() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setWorkspaceStatus('需要在 Tauri App 内发布');
|
||||
announcePublishMessage('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
const nextProjectPath =
|
||||
resolveChatProjectPath(localProject) ?? projectPath.trim();
|
||||
if (!nextProjectPath) {
|
||||
setWorkspaceStatus('先打开一个项目再发布');
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
const runExport = async () => {
|
||||
@@ -1224,7 +1235,9 @@ export function App({
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`);
|
||||
announcePublishMessage(
|
||||
`已构建并打包试玩包:${result.packageRelativePath}`,
|
||||
);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
@@ -1233,7 +1246,7 @@ export function App({
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
setWorkspaceStatus(
|
||||
announcePublishMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,10 +112,16 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到
|
||||
|
||||
### Rust 测试组编译对象快照
|
||||
|
||||
宿主下载每份 artifact 时核对 Gitea `size_in_bytes`、响应 `Content-Length`(若提供)和实际字节数,并在下载完成后立即检查 ZIP 格式与 CRC。截断、损坏归档或临时传输故障最多尝试 3 次,每次重新获取签名下载地址;不向签名地址转发 API Token。重试仍失败则删除临时下载并保留现役镜像,不能把 EOF 当作完整下载成功。
|
||||
|
||||
同一 sccache key 的完整对象 SHA 不同时,不直接视为编译结果不同:sccache 对象 ZIP 的成员写入顺序可能不同。仅对不同 job 新增对象之间的冲突,按成员名核对内容 SHA-256、权限及 ZIP 元数据,全部一致才保留一份原始对象并更新使用时间;真实内容差异、异常 ZIP 和继承对象冲突仍拒绝。此比较不改写缓存 key 或对象,不依赖 CRC 代替内容校验。
|
||||
|
||||
自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。六个 Rust job 仅在 master push 中导出本次 CI 新增的 sccache 对象;已命中的继承对象只上传新近使用时间,通过 Gitea 原生 V4 artifact 接口上传;PR 不发布。维护器选择已结束且六组产物完整的最新 master run,校验提交、任务尝试、工具链与来源镜像,与六组实际使用的同一镜像快照合并去重,并按新近使用时间限制快照总容量为 4 GiB,然后从无对象缓存基础镜像组装新镜像,**不重复执行 Cargo 预热编译,也不要求源 run 事先全绿**。缺组、取消或校验失败时保留现役版,不混合不同 run 的对象来假装完整快照。
|
||||
|
||||
维护 journal 分阶段记录来源 run、基础镜像重建或复用、artifact 下载、对象合并、镜像组装校验、导出、载入及空闲等待;长操作记录开始和结束耗时,失败输出对应私有 `artifacts/<source-sha>/build.log` 路径。构建的详细下载与 Docker 输出仍只写该日志,不回显 Token、命令环境或认证配置。
|
||||
|
||||
快照组装使用宿主 `default` Docker builder,读取已载入宿主的无对象缓存基础镜像;不能使用无法直接读取宿主镜像的独立 docker-container builder。BuildKit 的 `FROM` 不接受裸 `sha256:<Image ID>`,因此维护器在持锁期间将确定的基础 Image ID 绑定到专用临时 `assembly-base` tag,并在组装成功或失败后去除该 tag;来源元数据及 runner 配置仍使用完整 Image ID。临时别名不登记为受管基础镜像,不改变历史镜像的清理归属;进程被强杀时最多残留这一个别名,下次组装会重新绑定并清除。
|
||||
|
||||
切换先通过专属入口阻断新的 FetchTask,确认已转发的领取请求全部收到完整上游响应,并检查入口持久化跟踪的已领取任务全部结束、内层 Docker 没有活动容器。任务终态必须依据 Runner 的执行结束及最终上报协议,不能由容器暂时为空、API 已取消或请求超时推断。有任务即恢复领取并延后,不停止任务;状态未知拒绝切换。维护器只需普通账号的 `write:repository` Token(包括查询、下载及定向删除 artifact),不访问全局 Runner 管理 API。切换后等待使用该 Image ID 的完整真实 master push CI 通过,才允许下一次升级及旧镜像清理;不会自动重跑失败用例或为了验收额外触发整轮 CI。首次接管的历史镜像默认不归自动清理管理。
|
||||
|
||||
维护状态、凭据、归档和配置备份保存在仓库外。当前版、回滚版、待验证候选、它们的基础镜像及容器引用的镜像均受保护。清理只针对维护器登记的专属 tag、完整 Image ID 和专用目录中的归档;禁止全局 prune。API、构建、验证或空闲检查失败时保留现役镜像与回滚资料,不以失败重跑制造全绿结果。
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
- 发布入口灰度下发:`GET /api/runtime/frontend-config` 新增 `gameDistributionPublishEnabled`,复用既有 `is_game_distribution_publish_enabled_for_user`(未配置 `game-distribution:publish` 或 `enabled=false` 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中,匿名恒为 false),避免前端入口与写入口出现两套判据。网页端 `PlatformEntryActiveFlowShell` 据此隐藏「发布游戏 / 发布新版本」入口,`/games/publish` 直接访问时渲染「发布功能正在灰度中」并提供重新检查;AGC 端 `readGamePublishAvailability` 同样读该字段,只有命中才把发布回调交给 DirectProject 聊天头。
|
||||
- 灰度验证:`cargo test -p api-server frontend_runtime_config`(6 passed,含新增的 `frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate`:无 gate 行 → 登录作者 true/匿名 false;`enabled=true` 无白名单 → false;白名单命中 → true;`deny_user_ids` → false;`enabled=false` → true;`rolloutPercent=100` → true)、网页发布页 15 用例(含灰度未命中隐藏表单与「重新检查」放行)、平台壳 18 用例(含广场入口按灰度隐藏/显示)、AGC 发布服务 6 用例(含字段缺失与读取失败按不开放处理)。
|
||||
|
||||
- Phaser 一键发布闭环(作者不构建、不打 ZIP):`export_local_project_package` 改为发布前构建——已有可玩入口直接打包,否则解析 `game/` 或项目根的 npm `build` 脚本(`resolve_publish_build_plan`),缺 `game/node_modules` 时先跑 `project.bootstrap`,再走 `project.verify` 的受控 npm 运行器执行 build,最后校验入口并打包;构建或安装失败返回带日志尾部的可操作错误。真实 Phaser 4.2.1 + Vite 7 工程验证:构建产物使用相对引用(`./assets/...`),ZIP 370,969 B 经真实素材直传 + 创建游戏/版本/上传/送审/审核通过后,发行网关 `index.html` 200(323 B)与 `assets/index-DZGg_tPs.js` 200(1,388,719 B),网页播放页在 `allow-scripts` 沙箱 iframe 内渲染出 `PHASER-PUBLISH-OK` 与可点击按钮。
|
||||
- 发行网关根路径:`GET /api/game-distribution/releases/{gameId}` 与带尾斜杠的同一路径等价于 `index.html`(生产由每游戏 origin 映射根路径,本地直连网关或入口直接填网关地址时同样可玩);路由级用例覆盖 Cookie 拒绝门与根路径。
|
||||
|
||||
- 发布灰度改为**默认关闭**并修掉客户端“看得到点不动”:`is_game_distribution_publish_enabled_for_user` 现在要求 gate 行存在且 `enabled=true`(未登录、无行、`enabled=false` 一律 false),因此没配灰度时 `gameDistributionPublishEnabled=false`,AGC 不再渲染「发布到游戏广场」按钮、网页入口也不出现;AGC 侧新增 `announcePublishMessage`,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 `announce` 出口回话(普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点击无反应)。后台「灰度发布配置」新增「可配置开关」列表:预设开关在未创建行时也可见并可一键配置(不再需要先猜 gate key)。
|
||||
|
||||
## 尚未完成
|
||||
|
||||
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
|
||||
|
||||
@@ -797,6 +797,15 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
|
||||
- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`。
|
||||
|
||||
## 2026-09-22 旧创作模板历史表从 schema 与生成绑定中删除
|
||||
|
||||
- 背景:旧创作模板的 63 张历史玩法表已经完成业务代码退役;release 在 2026-09-22 apply 前仍有 26 张表、5922 行历史数据。维护窗口内先完成可恢复备份,再执行固定清单清空,继续保留 table 定义、迁移白名单、旧行兼容分支和生成绑定会让当前 schema、客户端类型和迁移合同长期停留在退役状态。
|
||||
- 决策:从 `spacetime-module` 可达源码删除旧 gameplay、自定义世界、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 与 Big Fish 的 63 张表定义及专属类型;同步删除只服务这些表的 `clear_retired_database_tables` procedure、固定清单、migration 导入导出项和旧行归一化分支,并重新生成 `spacetime-client` bindings。`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表和通用迁移能力保持不变。
|
||||
- 门禁处理:SpacetimeDB schema guard 只对本轮 63 个已确认 accessor 的从基线删除放行,其他表删除/改名以及字段级破坏性变更仍失败;该一次性白名单在删除结果进入后续主线基线后移除。生产发布仍必须单独完成冷备份、客户端兼容性和运行态确认,禁止用 SQL `DROP TABLE`、`--delete-data=always` 或系统表写入代替受控发布。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/{active.rs,migration.rs}`、旧表专属 module/legacy schema 文件、`server-rs/crates/spacetime-client/src/module_bindings*`、schema guard 及后端数据契约。
|
||||
- 生产数据与发布闭环:`genarrative-prod` 在维护态、API/controller/worker 已停止时,以已授权 migration operator 执行 `clear_retired_database_tables`;apply 返回 63 张表且每张 `cleared_row_count == row_count_before`,共清空 5922 行。apply 前后两次 `dry_run=true` 均为 63/63 全零,第二次在 10 分钟观察窗口结束后执行。清空前完成 files-minimal OSS 备份、latest/catalog 验真和 restore dry-run;随后将 release 从 `2.7.0` 直接切换到本地锁定并有 commit 校验的 `2.8.3 / 8e410d…` 二进制,再用 source commit `05a5ea4d8534b3f96d4d462c6cfda9b0775073df` 的 wasm 发布。发布后 schema 为 84 张表、旧表为 0、`clear_retired_database_tables` 为 0;API/controller/worker 已恢复,维护退出,`genarrative.world` 与 `www.genarrative.world` 返回 200。
|
||||
- 验证方式:`npm run spacetime:generate` 生成 766 个 binding 文件;`cargo check -p spacetime-client -p api-server`、`cargo test -p spacetime-module migration`(21 passed)、`npm run check:server-rs-ddd`、`npm run check:spacetime-schema`(84 tables)、`npm run check:spacetime-runtime-access`、schema guard 单测(9 passed)、`npm run check:encoding`、`npm run check:doc-index` 与 `git diff --check` 通过。
|
||||
|
||||
## 2026-09-02 旧玩法表采用两阶段退役清理
|
||||
|
||||
- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。
|
||||
|
||||
@@ -96,8 +96,12 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
|
||||
|
||||
## Gitea CI 依赖闭合
|
||||
|
||||
缓存维护下载必须核对 artifact 元数据大小与实际响应,并立即检查 ZIP 完整性;有长度上限不等于能检测短读。网络或归档损坏最多重试 3 次且重新取签名链接。sccache 同 key 的不同 ZIP 成员排列会改变整个对象 SHA;新增对象去重仅在成员内容 SHA、权限和 ZIP 元数据均一致时接受排列差异,真实内容及继承对象冲突仍拒绝。禁止通过任取一份冲突对象绕过完整性契约。
|
||||
|
||||
Buildx 0.30.1 的 `inspect` 不支持 `--format`,builder 驱动校验读取普通输出的 `Driver:` 字段。相关命令须在宿主真实插件上验证;测试替身应拒绝不支持的参数,避免把模拟命令成功误当兼容性证据。
|
||||
|
||||
BuildKit 会把 Dockerfile 中的裸 `FROM sha256:<Image ID>` 当作远程镜像名;快照组装须在维护锁内把可信基础 Image ID 绑定到专用临时 tag,并显式使用能读取宿主镜像的 `default` Docker builder。成功或失败后去 tag,元数据和 runner 仍固定完整 Image ID;不要复用受管 `base_tag` 给历史基础镜像打别名,以免改变自动清理归属。
|
||||
|
||||
Gitea 基础镜像通过专用 `genarrative-ci-images` Buildx builder 持久复用 Cargo/npm 下载缓存;稳定 cache mount 与 commit、lock 哈希无关,以 `sharing=locked` 隔离并发写入,仅供可信宿主构建、不开放给 PR。最终镜像显式物化当前依赖下载快照,仍不包含 node_modules/target 或上一版 sccache 层。首次可用 `seed-downloads` 从可信完整 Image ID 提取包缓存,操作账号须与维护服务一致;部署要求及 builder GC 空间目标见 `deploy/container/README.md`。构建上下文必须覆盖 AGC vendor 与编辑器 bridge 的全部本地 path manifest,普通源码变化不应使依赖层失效。维护 journal 提供阶段耗时和失败 build.log 定位。
|
||||
|
||||
Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 master push run 六个 Rust job 的原生 V4 缓存产物,不重复执行 Cargo 预热。只传本轮新 key,命中对象只传使用时间;宿主与真实来源镜像对象合并、去重、按新近使用时间裁剪到 4 GiB,从无对象缓存基础镜像重新组装。源 run 不要求全绿,但取消、缺组、旧 attempt、未完成上传或混用来源镜像不得采用。网关暂停新 FetchTask、在途领取结束、持久化任务账本清空且内层活动容器为空才切换,不打断运行中的 CI。首次接入/升级网关需空闲窗口;Token 只需普通仓库 `write:repository`,不查管理员 API。候选装载后清理已收集 artifact,遗留项保留 7 天;真实 master CI 验证后才清理旧镜像,保留当前、一个回滚版、基础镜像及容器引用。部署入口见 `deploy/container/README.md`,合并代码不等于服务启用。
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
按上游 [#5555](https://github.com/clockworklabs/SpacetimeDB/pull/5555) 的 retention 语义,只有最近 `retain-snapshots`(默认 2)份 snapshot 与覆盖它之后的 commitlog 段是重启所需,其余历史可丢;因此备份改为 `files + full + --minimal --retain-snapshots 2`(release 实测 40G → 3.6G,热备不停服),不再做增量差异计算,也不需要 44.7G 冷备空间。
|
||||
|
||||
2026-09-22 的 release 清表窗口再次验证:41.2GiB 数据目录执行 `archive` 会因根盘不足失败;尝试非 minimal 的 `files + full + --stop-service` 虽通过空间预检,但在扫描 43GiB 后于 catalog 序列化阶段报 `RangeError: Invalid string length`。生产中等数据目录继续使用可验真的 `files + full + --minimal` 备份,并在 apply 前执行 `restore-files-state --dry-run`;非 minimal files 大库备份需要先修复 catalog 序列化上限,不能把失败备份当作通过。
|
||||
|
||||
## copyArtifacts 报「Unable to find project for artifact copy」的用户触发构建差异
|
||||
|
||||
Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只有当被复制 Job 的 `CopyArtifactPermissionProperty`(仓库里由 Declarative 的 `copyArtifactPermission(...)` 维护)显式列出当前消费者,或者该 Job 对认证用户开放 Item.Read 时才放行;`ACL.SYSTEM2` 的定时构建会短路通过。因此会出现「定时调度一路成功、手动发布必挂」的现象(2026-09-21 手动发布 #6/#7 与同期的用户触发探测全部命中,定时调度 #104+ 正常)。`Genarrative-Agc-Global-Version-Issue` 生产权限模式的授权名单必须同时包含 `Genarrative-Scheduled-Revision-Trigger` 与 `Genarrative-Manual-Build-And-Deploy`;改完 `copyArtifactPermission` 后要先跑一次发号 Job 把 Job property 写回 Jenkins,只改仓库文件不生效。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -678,8 +678,8 @@ journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"'
|
||||
|
||||
发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启:
|
||||
|
||||
- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`。
|
||||
- 语义:没有该 gate 行或 `enabled=false` 表示**默认开放**;`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单即**全部关闭**(等价紧急关闭投稿)。
|
||||
- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`;后台「可配置开关」里固定列出「游戏分发 · 游戏发布」,点「配置」即按默认关闭填表(`enabled=false`),再填白名单 / 灰度比例并开启保存。灰度默认关闭:该键未创建或 `enabled=false` 时作者看不到发布入口、写入口返回 503。
|
||||
- 语义:没有该 gate 行或 `enabled=false` 表示**未开放**(灰度默认关闭);`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单同样全关(等价紧急关闭投稿)。开放灰度就是把 `enabled` 打开并放白名单或提高比例。
|
||||
- 关闭范围:创建游戏、创建版本、上传包、送审、撤回、作者下架,以及管理员**批准**(新版本激活)都返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`。
|
||||
- 始终可用:目录、详情、版本回读、发行网关(已公开游戏继续游玩)、`/my-games`、审核队列读取、**拒绝审核**与管理员**安全下架**。
|
||||
- 失败姿态:开关状态读取失败时按关闭处理,避免绕过运营刚下的收紧动作;本地排障时确认 SpacetimeDB 正常后再判断业务是否被误伤。
|
||||
@@ -1141,20 +1141,7 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
|
||||
- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。
|
||||
- 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`。
|
||||
- 该占位账号只用于作品作者域,不扩展到全站其它身份域。
|
||||
- 如需把历史孤儿作品批量回填到占位作者,使用 `scripts/rebind-orphan-work-owners.mjs` 先基于当前 auth 快照识别有效用户,再把缺失作者对应的作品表写回为占位 ID;脚本输入输出都基于 SpacetimeDB 迁移 JSON。
|
||||
|
||||
### 回填脚本用法
|
||||
|
||||
```bash
|
||||
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json>
|
||||
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --dry-run
|
||||
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json> --placeholder-user-id wx-openid-placeholder
|
||||
```
|
||||
|
||||
- `--in`:SpacetimeDB 导出的迁移 JSON。
|
||||
- `--out`:写回后的迁移 JSON 输出路径。
|
||||
- `--dry-run`:只统计回填行数,不写文件。
|
||||
- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。
|
||||
- 旧作品表退役后,迁移 JSON 回填工具 `scripts/rebind-orphan-work-owners.mjs` 已删除,不再提供回填脚本用法或当前操作入口;占位作者的显示与身份域语义继续保持上述规则。
|
||||
|
||||
## 维护页目标文件安全边界(2026-08-05)
|
||||
|
||||
|
||||
@@ -86,10 +86,10 @@
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
|
||||
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
|
||||
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
|
||||
|
||||
### 身份、状态、审核与更新
|
||||
@@ -119,7 +119,7 @@
|
||||
| --- | --- | --- |
|
||||
| `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 |
|
||||
| `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 |
|
||||
| `GET /game-distribution/releases/{gameId}/{assetPath}` | 游客 | **已实现**:发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
|
||||
| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
|
||||
| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 |
|
||||
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
|
||||
| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 |
|
||||
@@ -151,7 +151,7 @@
|
||||
- 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate`、`stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。
|
||||
- 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。
|
||||
- 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。
|
||||
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——没有 gate 行或 `enabled=false` 时默认开放;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即全部关闭)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
|
||||
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——灰度默认关闭,没有 gate 行或 `enabled=false` 时不允许发布;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即仍然全关)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
|
||||
|
||||
### 验收标准与证据
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
|
||||
// npm run check:game-distribution-media-e2e
|
||||
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
|
||||
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
|
||||
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
|
||||
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
|
||||
//
|
||||
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
|
||||
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
|
||||
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
|
||||
@@ -139,7 +144,39 @@ function gameMetadata(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
|
||||
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
|
||||
|
||||
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
|
||||
async function buildZip() {
|
||||
if (externalPackageZip) {
|
||||
const bytes = await readFile(externalPackageZip);
|
||||
const archive = new JSZip();
|
||||
const parsed = await archive.loadAsync(bytes);
|
||||
const entryNames = Object.keys(parsed.files).filter(
|
||||
(name) => !parsed.files[name].dir,
|
||||
);
|
||||
if (!entryNames.includes('index.html')) {
|
||||
throw new Error(
|
||||
`E2E_PACKAGE_ZIP 根目录缺少 index.html:${externalPackageZip}`,
|
||||
);
|
||||
}
|
||||
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
|
||||
const assetPath =
|
||||
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
|
||||
entryNames.find((name) => name.endsWith('.js'));
|
||||
if (!assetPath) {
|
||||
throw new Error(
|
||||
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
bytes: Buffer.from(bytes),
|
||||
fileCount: entryNames.length,
|
||||
assetPath,
|
||||
entryMarker: null,
|
||||
};
|
||||
}
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'index.html',
|
||||
@@ -147,7 +184,12 @@ async function buildZip() {
|
||||
);
|
||||
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
return Buffer.from(bytes);
|
||||
return {
|
||||
bytes: Buffer.from(bytes),
|
||||
fileCount: 2,
|
||||
assetPath: 'assets/app.js',
|
||||
entryMarker: 'E2E-MEDIA-OK',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -172,6 +214,78 @@ async function main() {
|
||||
});
|
||||
const another = otherEntry.data.token;
|
||||
|
||||
// 1.1 管理员登录:发布灰度默认关闭,脚本先验证关闭态再为本轮验证开启。
|
||||
const adminLogin = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||||
});
|
||||
check(
|
||||
'管理员登录成功',
|
||||
adminLogin.status === 200 &&
|
||||
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
|
||||
`status=${adminLogin.status}`,
|
||||
);
|
||||
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
|
||||
|
||||
const setPublishGate = (enabled, rolloutPercent) =>
|
||||
api('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token: admin,
|
||||
body: {
|
||||
gateKey: 'game-distribution:publish',
|
||||
enabled,
|
||||
rolloutPercent,
|
||||
allowUserIds: [],
|
||||
allowUserTags: [],
|
||||
denyUserIds: [],
|
||||
description: 'E2E 发布灰度',
|
||||
},
|
||||
});
|
||||
|
||||
const gateClosed = await setPublishGate(false, 0);
|
||||
check(
|
||||
'发布灰度可配置为关闭',
|
||||
gateClosed.status === 200,
|
||||
`status=${gateClosed.status}`,
|
||||
);
|
||||
|
||||
const closedAvailability = await api('/api/runtime/frontend-config', {
|
||||
token: author,
|
||||
});
|
||||
check(
|
||||
'灰度关闭时作者拿不到发布入口',
|
||||
closedAvailability.data?.gameDistributionPublishEnabled === false,
|
||||
`value=${closedAvailability.data?.gameDistributionPublishEnabled}`,
|
||||
);
|
||||
|
||||
const closedPublish = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` },
|
||||
body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }),
|
||||
});
|
||||
check(
|
||||
'灰度关闭时写入口 503',
|
||||
closedPublish.status === 503,
|
||||
`status=${closedPublish.status} code=${closedPublish.error?.code ?? ''}`,
|
||||
);
|
||||
|
||||
const gateOpen = await setPublishGate(true, 100);
|
||||
check(
|
||||
'发布灰度可开启并放量',
|
||||
gateOpen.status === 200,
|
||||
`status=${gateOpen.status}`,
|
||||
);
|
||||
|
||||
const openAvailability = await api('/api/runtime/frontend-config', {
|
||||
token: author,
|
||||
});
|
||||
check(
|
||||
'灰度开启后作者拿到发布入口',
|
||||
openAvailability.data?.gameDistributionPublishEnabled === true,
|
||||
`value=${openAvailability.data?.gameDistributionPublishEnabled}`,
|
||||
);
|
||||
|
||||
// 2. 真实素材直传
|
||||
const id = stamp();
|
||||
const cover = await uploadImage(author, 'cover', id);
|
||||
@@ -230,7 +344,7 @@ async function main() {
|
||||
|
||||
// 4. 创建游戏 + 版本(冻结资料)
|
||||
const metadata = gameMetadata({
|
||||
title: `分发媒体验证 ${id.slice(-6)}`,
|
||||
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
|
||||
coverAssetId: cover.assetObjectId,
|
||||
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
|
||||
});
|
||||
@@ -284,7 +398,8 @@ async function main() {
|
||||
`status=${ghostVersion.status}`,
|
||||
);
|
||||
|
||||
const zipBytes = await buildZip();
|
||||
const built = await buildZip();
|
||||
const zipBytes = built.bytes;
|
||||
const crypto = await import('node:crypto');
|
||||
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
|
||||
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
@@ -294,7 +409,7 @@ async function main() {
|
||||
body: {
|
||||
packageSha256: sha256,
|
||||
packageBytes: zipBytes.length,
|
||||
packageFileCount: 2,
|
||||
packageFileCount: built.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
@@ -401,19 +516,7 @@ async function main() {
|
||||
`status=${readBefore.status}`,
|
||||
);
|
||||
|
||||
// 7. 管理员审核通过(本地非生产允许回环 http 入口)
|
||||
const adminLogin = await api('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
|
||||
});
|
||||
check(
|
||||
'管理员登录成功',
|
||||
adminLogin.status === 200 &&
|
||||
Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken),
|
||||
`status=${adminLogin.status}`,
|
||||
);
|
||||
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
|
||||
|
||||
// 7. 管理员审核通过(本地非生产允许回环 http 入口;管理员 token 在步骤 1.1 已取得)
|
||||
const approved = await api(
|
||||
`/admin/api/game-distribution/versions/${versionId}/review`,
|
||||
{
|
||||
@@ -423,7 +526,8 @@ async function main() {
|
||||
body: {
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: readback.data.version.publicationRevision,
|
||||
entryUrl: `${API}`,
|
||||
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
|
||||
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -480,22 +584,27 @@ async function main() {
|
||||
`${API}/api/game-distribution/releases/${gameId}/index.html`,
|
||||
);
|
||||
const releaseBody = await release.text();
|
||||
const entryOk =
|
||||
release.status === 200 &&
|
||||
/<html|<!doctype html/iu.test(releaseBody) &&
|
||||
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
|
||||
check(
|
||||
'发行网关返回游戏入口',
|
||||
release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'),
|
||||
`status=${release.status}`,
|
||||
entryOk,
|
||||
`status=${release.status} bytes=${releaseBody.length}`,
|
||||
);
|
||||
check(
|
||||
'发行入口带 nosniff',
|
||||
release.headers.get('x-content-type-options') === 'nosniff',
|
||||
);
|
||||
const releaseAsset = await fetch(
|
||||
`${API}/api/game-distribution/releases/${gameId}/assets/app.js`,
|
||||
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
|
||||
);
|
||||
const assetBody = await releaseAsset.arrayBuffer();
|
||||
check(
|
||||
'发行网关返回包内资源',
|
||||
releaseAsset.status === 200,
|
||||
`status=${releaseAsset.status}`,
|
||||
releaseAsset.status === 200 && assetBody.byteLength > 0,
|
||||
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
|
||||
);
|
||||
|
||||
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
|
||||
|
||||
@@ -12,6 +12,80 @@ const tableCatalogPath =
|
||||
'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md';
|
||||
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
|
||||
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
|
||||
|
||||
// 本次历史表退役的一次性迁移白名单。该清单只允许这些已确认表从基线消失;
|
||||
// 待删除结果进入后续主线基线后必须删除本清单,其他表仍按既有破坏性变更路径失败。
|
||||
export const APPROVED_RETIRED_TABLE_DELETIONS = Object.freeze([
|
||||
'player_progression',
|
||||
'chapter_progression',
|
||||
'npc_state',
|
||||
'story_session',
|
||||
'story_event',
|
||||
'inventory_slot',
|
||||
'battle_state',
|
||||
'treasure_record',
|
||||
'quest_record',
|
||||
'quest_log',
|
||||
'custom_world_profile',
|
||||
'custom_world_session',
|
||||
'custom_world_agent_session',
|
||||
'custom_world_agent_message',
|
||||
'custom_world_agent_operation',
|
||||
'custom_world_draft_card',
|
||||
'custom_world_gallery_entry',
|
||||
'puzzle_agent_session',
|
||||
'puzzle_background_compile_task',
|
||||
'puzzle_agent_message',
|
||||
'puzzle_work_profile',
|
||||
'puzzle_event',
|
||||
'puzzle_runtime_run',
|
||||
'puzzle_leaderboard_entry',
|
||||
'puzzle_clear_agent_session',
|
||||
'puzzle_clear_work_profile',
|
||||
'puzzle_clear_runtime_run',
|
||||
'puzzle_clear_event',
|
||||
'bark_battle_draft_config',
|
||||
'bark_battle_published_config',
|
||||
'bark_battle_runtime_run',
|
||||
'bark_battle_score_record',
|
||||
'bark_battle_leaderboard_entry',
|
||||
'bark_battle_work_stats_projection',
|
||||
'bark_battle_personal_best_projection',
|
||||
'match3d_agent_session',
|
||||
'match3d_agent_message',
|
||||
'match_3_d_work_profile',
|
||||
'match3d_runtime_run',
|
||||
'jump_hop_agent_session',
|
||||
'jump_hop_work_profile',
|
||||
'jump_hop_runtime_run',
|
||||
'jump_hop_event',
|
||||
'jump_hop_leaderboard_entry',
|
||||
'wooden_fish_agent_session',
|
||||
'wooden_fish_work_profile',
|
||||
'wooden_fish_runtime_run',
|
||||
'wooden_fish_event',
|
||||
'square_hole_agent_session',
|
||||
'square_hole_agent_message',
|
||||
'square_hole_work_profile',
|
||||
'square_hole_runtime_run',
|
||||
'visual_novel_agent_session',
|
||||
'visual_novel_agent_message',
|
||||
'visual_novel_work_profile',
|
||||
'visual_novel_runtime_run',
|
||||
'visual_novel_runtime_history_entry',
|
||||
'visual_novel_runtime_event',
|
||||
'big_fish_creation_session',
|
||||
'big_fish_agent_message',
|
||||
'big_fish_asset_slot',
|
||||
'big_fish_runtime_run',
|
||||
'big_fish_event',
|
||||
]);
|
||||
const approvedRetiredTableDeletions = new Set(APPROVED_RETIRED_TABLE_DELETIONS);
|
||||
|
||||
export function isApprovedRetiredTableDeletion(accessor) {
|
||||
return approvedRetiredTableDeletions.has(accessor);
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
return path.replace(/\\/gu, '/');
|
||||
}
|
||||
@@ -639,7 +713,7 @@ function fieldDescription(field) {
|
||||
return `${field.name}: ${field.type}`;
|
||||
}
|
||||
|
||||
function compareTables(baseTables, currentTables) {
|
||||
export function compareTables(baseTables, currentTables) {
|
||||
const failures = [];
|
||||
let schemaChanged = false;
|
||||
let breakingChanged = false;
|
||||
@@ -648,6 +722,9 @@ function compareTables(baseTables, currentTables) {
|
||||
const currentTable = currentTables.get(accessor);
|
||||
if (!currentTable) {
|
||||
schemaChanged = true;
|
||||
if (isApprovedRetiredTableDeletion(accessor)) {
|
||||
continue;
|
||||
}
|
||||
breakingChanged = true;
|
||||
failures.push(
|
||||
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
APPROVED_RETIRED_TABLE_DELETIONS,
|
||||
collectTablesFromSources,
|
||||
compareTables,
|
||||
isFormalGateEnvironment,
|
||||
listReachableRustFiles,
|
||||
resolveBaseRef,
|
||||
@@ -36,6 +38,15 @@ function createGitResolver(entries: Record<string, string | null>) {
|
||||
return (args: string[]) => entries[args.join(' ')] ?? null;
|
||||
}
|
||||
|
||||
function collectTables(accessors: string[]) {
|
||||
return collectTablesFromSources(
|
||||
accessors.map((accessor, index) => ({
|
||||
path: `${sourceRoot}/schema.rs`,
|
||||
text: tableSource(`Table${index}`, accessor),
|
||||
})),
|
||||
).tables;
|
||||
}
|
||||
|
||||
describe('SpacetimeDB schema guard base resolution', () => {
|
||||
it('prefers an explicit Jenkins-provided base ref', () => {
|
||||
expect(
|
||||
@@ -125,3 +136,61 @@ describe('SpacetimeDB schema guard module reachability', () => {
|
||||
expect(result.failures[0]).toMatch(/table accessor example 重复定义/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpacetimeDB schema guard retired table deletion allowlist', () => {
|
||||
it('allows exactly the 63 approved historical tables to disappear', () => {
|
||||
expect(APPROVED_RETIRED_TABLE_DELETIONS).toHaveLength(63);
|
||||
expect(new Set(APPROVED_RETIRED_TABLE_DELETIONS).size).toBe(63);
|
||||
|
||||
const result = compareTables(
|
||||
collectTables([...APPROVED_RETIRED_TABLE_DELETIONS]),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.schemaChanged).toBe(true);
|
||||
expect(result.breakingChanged).toBe(false);
|
||||
});
|
||||
|
||||
it('still rejects deletion of a table outside the approved list', () => {
|
||||
const result = compareTables(
|
||||
collectTables([APPROVED_RETIRED_TABLE_DELETIONS[0], 'active_table']),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]).toMatch(/active_table.*被删除或改名/u);
|
||||
expect(result.schemaChanged).toBe(true);
|
||||
expect(result.breakingChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress retained-table field deletion or rename checks', () => {
|
||||
const baseTables = collectTablesFromSources([
|
||||
{
|
||||
path: `${sourceRoot}/ranks.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n pub title: String,\n}\n`,
|
||||
},
|
||||
{
|
||||
path: `${sourceRoot}/titles.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub title: String,\n}\n`,
|
||||
},
|
||||
]).tables;
|
||||
const currentTables = collectTablesFromSources([
|
||||
{
|
||||
path: `${sourceRoot}/ranks.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n}\n`,
|
||||
},
|
||||
{
|
||||
path: `${sourceRoot}/titles.rs`,
|
||||
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub name: String,\n}\n`,
|
||||
},
|
||||
]).tables;
|
||||
|
||||
const result = compareTables(baseTables, currentTables);
|
||||
|
||||
expect(result.failures).toHaveLength(2);
|
||||
expect(result.failures.join('\n')).toMatch(/字段数量减少/u);
|
||||
expect(result.failures.join('\n')).toMatch(/字段被删除或改名/u);
|
||||
expect(result.breakingChanged).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,6 +167,14 @@ def _snapshot_member(archive: Path) -> tuple[zipfile.ZipFile, zipfile.ZipInfo]:
|
||||
raise
|
||||
|
||||
|
||||
def validate_artifact_zip(archive: Path) -> None:
|
||||
"""Validate the bounded stored ZIP envelope before consuming an artifact."""
|
||||
bundle, _ = _snapshot_member(archive)
|
||||
with bundle:
|
||||
if bundle.testzip() is not None:
|
||||
raise SnapshotError("artifact ZIP CRC check failed")
|
||||
|
||||
|
||||
def _tar_stream(archive: Path):
|
||||
bundle, member = _snapshot_member(archive)
|
||||
try:
|
||||
@@ -440,6 +448,94 @@ def _file_sha256(path: Path) -> str:
|
||||
digest.update(chunk)
|
||||
|
||||
|
||||
def _object_zip_signature(stream: BinaryIO, expected: _Object) -> tuple:
|
||||
"""只允许 sccache 对象的 ZIP 成员排列不同,不放宽内容或元数据校验。"""
|
||||
with tempfile.TemporaryFile() as temporary:
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > expected.size:
|
||||
raise SnapshotError("cache object grew while comparing ZIP contents")
|
||||
digest.update(chunk)
|
||||
temporary.write(chunk)
|
||||
if (total, digest.hexdigest()) != (expected.size, expected.sha256):
|
||||
raise SnapshotError("cache object checksum changed while comparing ZIP contents")
|
||||
temporary.seek(0)
|
||||
with zipfile.ZipFile(temporary) as bundle:
|
||||
infos = bundle.infolist()
|
||||
if (not infos or len(infos) > MAX_OBJECTS
|
||||
or len({info.filename for info in infos}) != len(infos)
|
||||
or sum(info.file_size for info in infos) > expected.size):
|
||||
raise SnapshotError("invalid sccache ZIP member set")
|
||||
members = []
|
||||
for info in sorted(infos, key=lambda entry: entry.filename):
|
||||
_safe_zip_path(info.filename)
|
||||
mode = info.external_attr >> 16
|
||||
if (info.is_dir() or info.flag_bits & 1 or stat.S_ISLNK(mode)
|
||||
or stat.S_IFMT(mode) not in (0, stat.S_IFREG)
|
||||
or info.compress_type != zipfile.ZIP_STORED):
|
||||
raise SnapshotError("unsupported sccache ZIP member")
|
||||
with bundle.open(info) as contents:
|
||||
size, checksum = _hash_stream(contents, info.file_size)
|
||||
members.append((
|
||||
info.filename, size, checksum, info.CRC, info.compress_size,
|
||||
info.compress_type, info.date_time, info.flag_bits,
|
||||
info.external_attr, info.internal_attr, info.create_system,
|
||||
info.create_version, info.extract_version, info.reserved,
|
||||
info.extra, info.comment,
|
||||
))
|
||||
return bundle.comment, tuple(members)
|
||||
|
||||
|
||||
def _equivalent_delta_paths(archives: Sequence[_Archive]) -> set[str]:
|
||||
variants: dict[str, set[tuple[int, str | None]]] = {}
|
||||
for archive in archives:
|
||||
for obj in archive.objects:
|
||||
variants.setdefault(obj.path, set()).add((obj.size, obj.sha256))
|
||||
conflicts = {path for path, versions in variants.items() if len(versions) > 1}
|
||||
if not conflicts:
|
||||
return conflicts
|
||||
for path in conflicts:
|
||||
if len({size for size, _ in variants[path]}) != 1:
|
||||
raise SnapshotError(f"conflicting content for duplicate object: {path}")
|
||||
signatures = {}
|
||||
# 每份归档最多额外顺序读取一次,仅将冲突对象暂存到磁盘供 ZIP 随机读取。
|
||||
for archive in archives:
|
||||
wanted = {obj.path: obj for obj in archive.objects if obj.path in conflicts}
|
||||
if not wanted:
|
||||
continue
|
||||
if _archive_signature(archive.input.archive) != archive.signature:
|
||||
raise SnapshotError("artifact archive changed while comparing cache objects")
|
||||
bundle, raw, tar = _tar_stream(archive.input.archive)
|
||||
try:
|
||||
for member in tar:
|
||||
expected = wanted.get(member.name)
|
||||
if expected is None:
|
||||
continue
|
||||
if not member.isreg() or member.size != expected.size:
|
||||
raise SnapshotError("cache object changed while comparing ZIP contents")
|
||||
stream = tar.extractfile(member)
|
||||
if stream is None:
|
||||
raise SnapshotError("cache object is missing while comparing ZIP contents")
|
||||
try:
|
||||
with stream:
|
||||
signature = _object_zip_signature(stream, expected)
|
||||
except (SnapshotError, zipfile.BadZipFile, NotImplementedError) as error:
|
||||
raise SnapshotError(f"conflicting content for duplicate object: {member.name}") from error
|
||||
if member.name in signatures and signatures[member.name] != signature:
|
||||
raise SnapshotError(f"conflicting content for duplicate object: {member.name}")
|
||||
signatures[member.name] = signature
|
||||
del wanted[member.name]
|
||||
if wanted:
|
||||
raise SnapshotError("cache objects disappeared while comparing ZIP contents")
|
||||
finally:
|
||||
tar.close()
|
||||
raw.close()
|
||||
bundle.close()
|
||||
return conflicts
|
||||
|
||||
|
||||
def _select_objects(
|
||||
archives: Sequence[_Archive],
|
||||
base_root: Path,
|
||||
@@ -447,6 +543,7 @@ def _select_objects(
|
||||
maximum: int,
|
||||
) -> tuple[_Object, ...]:
|
||||
merged = dict(base)
|
||||
equivalent_paths = _equivalent_delta_paths(archives)
|
||||
delta_paths = {obj.path for archive in archives for obj in archive.objects}
|
||||
touched_paths = {touch.path for archive in archives for touch in archive.touched}
|
||||
overlap = delta_paths & touched_paths
|
||||
@@ -490,7 +587,8 @@ def _select_objects(
|
||||
mtime_ns=max(current.mtime_ns, candidate.mtime_ns),
|
||||
source_index=None,
|
||||
)
|
||||
elif (current.size, current.sha256) != (candidate.size, candidate.sha256):
|
||||
elif ((current.size, current.sha256) != (candidate.size, candidate.sha256)
|
||||
and candidate.path not in equivalent_paths):
|
||||
raise SnapshotError(f"conflicting content for duplicate object: {candidate.path}")
|
||||
elif candidate.mtime_ns > current.mtime_ns:
|
||||
merged[candidate.path] = _Object(
|
||||
|
||||
@@ -5,6 +5,7 @@ import argparse
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -18,8 +19,11 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
from gitea_cache_snapshot import ArtifactIdentity, ArtifactInput, merge_snapshots
|
||||
from gitea_cache_snapshot import (
|
||||
ArtifactIdentity, ArtifactInput, SnapshotError, merge_snapshots, validate_artifact_zip,
|
||||
)
|
||||
from gitea_cache_upload_cleanup import cleanup_upload_chunks
|
||||
|
||||
|
||||
@@ -46,6 +50,7 @@ RUST_JOBS = set(RUST_JOB_IDS)
|
||||
ARTIFACT_PREFIX = "rust-cache-v1-"
|
||||
MAX_DOWNLOAD = 4 * 1024 ** 3 + 129 * 1024 ** 2
|
||||
EXPORT_STEP = "Publish master Rust cache artifact"
|
||||
DOWNLOAD_ATTEMPTS = 3
|
||||
|
||||
|
||||
def now():
|
||||
@@ -183,35 +188,90 @@ class Api:
|
||||
raise RuntimeError(f"Gitea API HTTP {error.code}") from None
|
||||
return content if raw else (json.loads(content) if content else None)
|
||||
|
||||
def download(self, path, destination):
|
||||
"""REST V4 archive redirects to a signed URL; never forward the API token."""
|
||||
def download(self, path, destination, expected_size):
|
||||
"""Fetch one artifact archive, retrying incomplete signed-URL transfers."""
|
||||
if (isinstance(expected_size, bool) or not isinstance(expected_size, int)
|
||||
or expected_size <= 0):
|
||||
raise RuntimeError("artifact has invalid size metadata")
|
||||
if expected_size > MAX_DOWNLOAD:
|
||||
raise RuntimeError("artifact exceeds per-job size limit")
|
||||
|
||||
for attempt in range(DOWNLOAD_ATTEMPTS):
|
||||
try:
|
||||
self._download_once(path, destination, expected_size)
|
||||
return
|
||||
except RetryableArtifactDownload as error:
|
||||
destination.unlink(missing_ok=True)
|
||||
log(f"artifact download attempt {attempt + 1}/{DOWNLOAD_ATTEMPTS} failed: {error}")
|
||||
if attempt + 1 == DOWNLOAD_ATTEMPTS:
|
||||
raise RuntimeError("artifact download remained incomplete after retries") from error
|
||||
time.sleep(1 << attempt)
|
||||
except Exception:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _download_once(self, path, destination, expected_size):
|
||||
"""Obtain a fresh signed URL and validate its complete ZIP response."""
|
||||
token = self.token_file.read_text().strip()
|
||||
url = self.url + "/" + path.lstrip("/")
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
request = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||
target = None
|
||||
try:
|
||||
response = opener.open(request, timeout=60)
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close()
|
||||
if error.code not in (301, 302, 303, 307, 308):
|
||||
raise RuntimeError(f"artifact download HTTP {error.code}") from None
|
||||
target = urllib.parse.urljoin(url, error.headers.get("Location", ""))
|
||||
parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url)
|
||||
if (parsed.scheme != "https" or parsed.netloc != origin.netloc
|
||||
or parsed.username or parsed.password or target == url):
|
||||
raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None
|
||||
response = opener.open(target, timeout=60)
|
||||
try:
|
||||
with response, destination.open("wb") as out:
|
||||
total = 0
|
||||
while chunk := response.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_DOWNLOAD:
|
||||
try:
|
||||
response = opener.open(request, timeout=60)
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close()
|
||||
if error.code not in (301, 302, 303, 307, 308):
|
||||
if error.code >= 500:
|
||||
raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None
|
||||
raise RuntimeError(f"artifact download HTTP {error.code}") from None
|
||||
target = urllib.parse.urljoin(url, error.headers.get("Location", ""))
|
||||
parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url)
|
||||
if (parsed.scheme != "https" or parsed.netloc != origin.netloc
|
||||
or parsed.username or parsed.password or target == url):
|
||||
raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None
|
||||
if target is not None:
|
||||
try:
|
||||
response = opener.open(target, timeout=60)
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close()
|
||||
if error.code >= 500:
|
||||
raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None
|
||||
raise RuntimeError(f"artifact download HTTP {error.code}") from None
|
||||
with response:
|
||||
content_length = getattr(response, "headers", {}).get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
content_length = int(content_length)
|
||||
except (TypeError, ValueError):
|
||||
raise RetryableArtifactDownload("artifact response has invalid Content-Length") from None
|
||||
if content_length > MAX_DOWNLOAD:
|
||||
raise RuntimeError("artifact exceeds per-job size limit")
|
||||
out.write(chunk)
|
||||
except Exception:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
if content_length != expected_size:
|
||||
raise RetryableArtifactDownload(
|
||||
f"artifact response size differs: expected {expected_size} bytes, "
|
||||
f"Content-Length is {content_length}")
|
||||
with destination.open("wb") as out:
|
||||
total = 0
|
||||
while chunk := response.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > MAX_DOWNLOAD:
|
||||
raise RuntimeError("artifact exceeds per-job size limit")
|
||||
if total > expected_size:
|
||||
raise RetryableArtifactDownload(
|
||||
f"artifact response exceeds metadata: expected {expected_size} bytes, "
|
||||
f"received at least {total}")
|
||||
out.write(chunk)
|
||||
if total != expected_size:
|
||||
raise RetryableArtifactDownload(
|
||||
f"artifact response is truncated: expected {expected_size} bytes, received {total}")
|
||||
try:
|
||||
validate_artifact_zip(destination)
|
||||
except (SnapshotError, zipfile.BadZipFile):
|
||||
raise RetryableArtifactDownload("artifact response is not a valid ZIP") from None
|
||||
except (http.client.HTTPException, urllib.error.URLError, OSError) as error:
|
||||
raise RetryableArtifactDownload("artifact transfer failed") from error
|
||||
|
||||
def pages(self, path, key):
|
||||
separator = "&" if "?" in path else "?"
|
||||
@@ -230,6 +290,10 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
return None
|
||||
|
||||
|
||||
class RetryableArtifactDownload(RuntimeError):
|
||||
"""A signed archive transfer may be retried with a newly issued URL."""
|
||||
|
||||
|
||||
class Maintenance:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
@@ -371,7 +435,10 @@ class Maintenance:
|
||||
if len(used) != 1:
|
||||
break
|
||||
images.update(used)
|
||||
selected.append({"id": matches[0]["id"], "name": name,
|
||||
size = matches[0].get("size_in_bytes")
|
||||
if isinstance(size, bool) or not isinstance(size, int) or size <= 0 or size > MAX_DOWNLOAD:
|
||||
break
|
||||
selected.append({"id": matches[0]["id"], "name": name, "size_in_bytes": size,
|
||||
"job": RUST_JOB_IDS[job["name"]], "attempt": job["run_attempt"]})
|
||||
if len(selected) != len(RUST_JOB_IDS) or len(images) != 1:
|
||||
continue
|
||||
@@ -431,7 +498,8 @@ class Maintenance:
|
||||
archive = work / (str(export["id"]) + ".zip")
|
||||
with operation(f"download cache artifact job={export['job']} attempt={export['attempt']}",
|
||||
build_log=build_log):
|
||||
self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive)
|
||||
self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive,
|
||||
export["size_in_bytes"])
|
||||
inputs.append(ArtifactInput(archive, ArtifactIdentity(
|
||||
self.config["repository"], source["run_id"], export["attempt"], export["job"], sha)))
|
||||
snapshot = work / "snapshot"
|
||||
@@ -451,14 +519,22 @@ class Maintenance:
|
||||
shutil.copyfile(inherited / "sccache", snapshot / "sccache")
|
||||
(snapshot / "sccache").chmod(0o755)
|
||||
(snapshot / "base-image.txt").write_text(base + "\n")
|
||||
# BuildKit 的 FROM 不接受裸 Image ID;维护锁内使用一个临时本地别名。
|
||||
# 别名不登记为 owned base,避免把接管前的基础镜像纳入自动清理。
|
||||
assembly_base = "genarrative/gitea-project-ci:assembly-base"
|
||||
(work / "Dockerfile").write_text(
|
||||
f"FROM {base}\nCOPY snapshot/ /opt/genarrative-ci/rust-cache/\n"
|
||||
f"FROM {assembly_base}\nCOPY snapshot/ /opt/genarrative-ci/rust-cache/\n"
|
||||
f'LABEL world.genarrative.ci.rust-cache-source="{sha}"\n'
|
||||
f'LABEL world.genarrative.ci.rust-cache-base="{base}"\n')
|
||||
(work / ".dockerignore").write_text("**\n!Dockerfile\n!snapshot/\n!snapshot/**\n")
|
||||
with operation("assemble cache candidate image", build_log=build_log):
|
||||
with build_log.open("a") as out:
|
||||
self.docker("build", "--pull=false", "--tag", tag, str(work), output=out, timeout=1800)
|
||||
self.docker("image", "tag", base, assembly_base)
|
||||
try:
|
||||
with build_log.open("a") as out:
|
||||
self.docker("build", "--builder", "default", "--pull=false", "--tag", tag,
|
||||
str(work), output=out, timeout=1800)
|
||||
finally:
|
||||
self.docker("image", "rm", assembly_base)
|
||||
self.build_command(build_log, "gitea-ci-job-image.sh", "verify", tag, env=env,
|
||||
description="verify cache candidate image")
|
||||
image = self.image_info(tag)["Id"]
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
|
||||
|
||||
export const WORK_OWNER_TABLES = [
|
||||
'custom_world_profile',
|
||||
'custom_world_gallery_entry',
|
||||
'custom_world_session',
|
||||
'custom_world_agent_session',
|
||||
'custom_world_draft_card',
|
||||
'puzzle_agent_session',
|
||||
'puzzle_work_profile',
|
||||
'bark_battle_draft_config',
|
||||
'bark_battle_published_config',
|
||||
'match3d_agent_session',
|
||||
'match3d_work_profile',
|
||||
'jump_hop_agent_session',
|
||||
'jump_hop_work_profile',
|
||||
'wooden_fish_agent_session',
|
||||
'wooden_fish_work_profile',
|
||||
'square_hole_agent_session',
|
||||
'square_hole_work_profile',
|
||||
'visual_novel_agent_session',
|
||||
'visual_novel_work_profile',
|
||||
'big_fish_creation_session',
|
||||
];
|
||||
|
||||
const ROW_KEY_FIELDS = [
|
||||
'profile_id',
|
||||
'work_id',
|
||||
'session_id',
|
||||
'draft_id',
|
||||
'gallery_entry_id',
|
||||
'id',
|
||||
];
|
||||
|
||||
if (isCliEntry()) {
|
||||
runCli(process.argv.slice(2)).catch((error) => {
|
||||
console.error(
|
||||
`[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export function rebindOrphanWorkOwnersInMigration(
|
||||
migration,
|
||||
{
|
||||
placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
|
||||
validUserIds = [],
|
||||
} = {},
|
||||
) {
|
||||
if (!migration || !Array.isArray(migration.tables)) {
|
||||
throw new Error('迁移 JSON 必须包含 tables 数组。');
|
||||
}
|
||||
|
||||
const normalizedPlaceholderUserId = placeholderUserId.trim();
|
||||
const validUserIdSet = new Set(
|
||||
(Array.isArray(validUserIds) ? validUserIds : [])
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
validUserIdSet.add(normalizedPlaceholderUserId);
|
||||
|
||||
const reboundRows = [];
|
||||
for (const table of migration.tables) {
|
||||
if (
|
||||
!table ||
|
||||
!WORK_OWNER_TABLES.includes(table.name) ||
|
||||
!Array.isArray(table.rows)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const row of table.rows) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const currentOwner =
|
||||
typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
|
||||
if (
|
||||
currentOwner === normalizedPlaceholderUserId ||
|
||||
validUserIdSet.has(currentOwner)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalOwner =
|
||||
typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
|
||||
row.owner_user_id = normalizedPlaceholderUserId;
|
||||
reboundRows.push({
|
||||
table: table.name,
|
||||
rowKey: resolveRowKey(row),
|
||||
from: originalOwner,
|
||||
to: normalizedPlaceholderUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { reboundRows, validUserCount: validUserIdSet.size };
|
||||
}
|
||||
|
||||
function resolveRowKey(row) {
|
||||
for (const field of ROW_KEY_FIELDS) {
|
||||
const value = row[field];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '<unknown>';
|
||||
}
|
||||
|
||||
async function runCli(argv) {
|
||||
const options = parseCliArgs(argv);
|
||||
const inputPath = path.resolve(options.in);
|
||||
const outputPath = path.resolve(options.out);
|
||||
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
|
||||
const result = rebindOrphanWorkOwnersInMigration(migration, {
|
||||
placeholderUserId: options.placeholderUserId,
|
||||
validUserIds: collectValidUserIds(migration),
|
||||
});
|
||||
|
||||
if (!options.dryRun) {
|
||||
await writeFile(
|
||||
outputPath,
|
||||
`${JSON.stringify(migration, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length} 行`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const options = {
|
||||
in: '',
|
||||
out: '',
|
||||
placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (arg === '--in') {
|
||||
options.in = readValue(arg);
|
||||
} else if (arg === '--out') {
|
||||
options.out = readValue(arg);
|
||||
} else if (arg === '--placeholder-user-id') {
|
||||
options.placeholderUserId = readValue(arg);
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.in) {
|
||||
throw new Error('必须传入 --in。');
|
||||
}
|
||||
if (!options.out && !options.dryRun) {
|
||||
throw new Error('非 dry-run 必须传入 --out。');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function collectValidUserIds(migration) {
|
||||
const result = new Set();
|
||||
for (const table of migration.tables ?? []) {
|
||||
if (!table || !Array.isArray(table.rows)) {
|
||||
continue;
|
||||
}
|
||||
if (table.name === 'user_account') {
|
||||
for (const row of table.rows) {
|
||||
if (typeof row?.user_id === 'string' && row.user_id.trim()) {
|
||||
result.add(row.user_id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCliEntry() {
|
||||
const entry = process.argv[1];
|
||||
return entry
|
||||
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
|
||||
: false;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
|
||||
|
||||
const placeholderUserId = 'wx-openid-placeholder';
|
||||
|
||||
function table(name, rows) {
|
||||
return { name, rows };
|
||||
}
|
||||
|
||||
describe('rebindOrphanWorkOwnersInMigration', () => {
|
||||
it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
|
||||
const migration = {
|
||||
schema_version: 1,
|
||||
exported_at_micros: 1,
|
||||
tables: [
|
||||
table('user_account', [
|
||||
{ user_id: 'user_alive' },
|
||||
{ user_id: placeholderUserId },
|
||||
]),
|
||||
table('puzzle_work_profile', [
|
||||
{ profile_id: 'p1', owner_user_id: 'user_missing' },
|
||||
{ profile_id: 'p2', owner_user_id: 'user_alive' },
|
||||
{ profile_id: 'p3', owner_user_id: placeholderUserId },
|
||||
]),
|
||||
table('puzzle_agent_session', [
|
||||
{ session_id: 'draft-1', owner_user_id: '' },
|
||||
]),
|
||||
table('tracking_event', [
|
||||
{ event_id: 't1', owner_user_id: 'user_missing' },
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
const result = rebindOrphanWorkOwnersInMigration(migration, {
|
||||
placeholderUserId,
|
||||
validUserIds: ['user_alive'],
|
||||
});
|
||||
|
||||
expect(result.reboundRows).toEqual([
|
||||
{
|
||||
table: 'puzzle_work_profile',
|
||||
rowKey: 'p1',
|
||||
from: 'user_missing',
|
||||
to: placeholderUserId,
|
||||
},
|
||||
{
|
||||
table: 'puzzle_agent_session',
|
||||
rowKey: 'draft-1',
|
||||
from: '',
|
||||
to: placeholderUserId,
|
||||
},
|
||||
]);
|
||||
expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
|
||||
expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).with_name("maintain-gitea-rust-cache.py")
|
||||
@@ -29,6 +30,26 @@ def runner_config(image: str) -> str:
|
||||
return f'runners:\n - "genarrative-ci:docker://{image}"\n'
|
||||
|
||||
|
||||
def zip_bytes(*, size=None) -> bytes:
|
||||
"""Make a valid stored ZIP, optionally padded to an exact download size."""
|
||||
content = b"x" * 40000 if size else b"cache artifact"
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_STORED) as archive:
|
||||
archive.writestr("snapshot.tar", content)
|
||||
value = output.getvalue()
|
||||
if size is None:
|
||||
return value
|
||||
if not len(value) < size <= len(value) + 65535:
|
||||
raise AssertionError("requested ZIP size cannot be represented by its comment")
|
||||
output = io.BytesIO(value)
|
||||
with zipfile.ZipFile(output, "a") as archive:
|
||||
archive.comment = b"p" * (size - len(value))
|
||||
value = output.getvalue()
|
||||
if len(value) != size:
|
||||
raise AssertionError("ZIP comment did not produce the requested size")
|
||||
return value
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self):
|
||||
self.requests: list[str] = []
|
||||
@@ -299,7 +320,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
"steps": [{"name": maintenance_module.EXPORT_STEP, "conclusion": "success"}]}
|
||||
for i, name in enumerate(maintenance_module.RUST_JOB_IDS, start=1)]
|
||||
artifacts = [{"id": job["id"], "name": "rust-cache-v1-" + maintenance_module.RUST_JOB_IDS[job["name"]]
|
||||
+ "-attempt-1", "expired": False, "workflow_run": run} for job in jobs]
|
||||
+ "-attempt-1", "size_in_bytes": 10, "expired": False, "workflow_run": run} for job in jobs]
|
||||
class Api:
|
||||
def pages(self, path, key):
|
||||
return iter({"workflow_runs": [run], "jobs": jobs, "artifacts": artifacts}[key])
|
||||
@@ -320,6 +341,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
result = self.select_source(instance)
|
||||
self.assertEqual(result["run_id"], 44)
|
||||
self.assertEqual(len(result["exports"]), 6)
|
||||
self.assertEqual({item["size_in_bytes"] for item in result["exports"]}, {10})
|
||||
run["event"] = "pull_request"
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
run["event"] = "push"
|
||||
@@ -352,15 +374,27 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
self.assertIsNone(self.select_source(instance))
|
||||
|
||||
def test_assembly_uses_ci_objects_and_trusted_binary_without_warming_compiler(self):
|
||||
self.assert_assembly()
|
||||
|
||||
def test_assembly_uses_rebuilt_local_base(self):
|
||||
self.assert_assembly(rebuild=True)
|
||||
|
||||
def test_failed_assembly_removes_temporary_base_alias(self):
|
||||
self.assert_assembly(build_fails=True)
|
||||
|
||||
def assert_assembly(self, *, rebuild=False, build_fails=False):
|
||||
instance, _, _, _ = self.source_fixture()
|
||||
source = self.select_source(instance)
|
||||
shell_calls, docker_calls = [], []
|
||||
base_tag = "genarrative/gitea-project-ci:base-auto-" + SHA
|
||||
assembly_base = "genarrative/gitea-project-ci:assembly-base"
|
||||
instance.build_command = lambda log, script, *args, **kw: shell_calls.append((script, args))
|
||||
def info(image, inner=False):
|
||||
return {"Id": IMAGE if image.startswith("genarrative/") else image, "Config": {"Labels": {
|
||||
image_id = BASE_IMAGE if image == base_tag else IMAGE if image.startswith("genarrative/") else image
|
||||
return {"Id": image_id, "Config": {"Labels": {
|
||||
"world.genarrative.ci.rust-cache-source": "b" * 40,
|
||||
"world.genarrative.ci.rust-cache-base": BASE_IMAGE,
|
||||
"com.genarrative.ci.definition-sha256": "definition",
|
||||
"com.genarrative.ci.definition-sha256": "old-definition" if rebuild else "definition",
|
||||
}}}
|
||||
instance.image_info = info
|
||||
def docker(*args, **kw):
|
||||
@@ -373,9 +407,19 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
(root / "sccache").write_bytes(b"trusted binary")
|
||||
if args[-2:] == ("rustc", "-vV"):
|
||||
return "rustc test\n"
|
||||
if args[0] == "build":
|
||||
self.assertEqual(args[1:5], ("--builder", "default", "--pull=false", "--tag"))
|
||||
work = Path(args[-1])
|
||||
self.assertTrue((work / "Dockerfile").read_text().startswith(f"FROM {assembly_base}\n"))
|
||||
self.assertIn(f'world.genarrative.ci.rust-cache-base="{BASE_IMAGE}"',
|
||||
(work / "Dockerfile").read_text())
|
||||
self.assertEqual((work / "snapshot/base-image.txt").read_text(), BASE_IMAGE + "\n")
|
||||
self.assertEqual(docker_calls[-2], ("image", "tag", BASE_IMAGE, assembly_base))
|
||||
if build_fails:
|
||||
raise RuntimeError("assembly failed")
|
||||
return ""
|
||||
instance.docker = docker
|
||||
instance.api.download = lambda path, destination: destination.write_bytes(b"download")
|
||||
instance.api.download = lambda path, destination, expected_size: destination.write_bytes(b"download")
|
||||
def merge(inputs, output, **kwargs):
|
||||
self.assertEqual(len(inputs), 6)
|
||||
self.assertEqual(kwargs["expected_inherited_source_sha"], "b" * 40)
|
||||
@@ -385,10 +429,22 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
"workspace": "/workspace/team/project", "base_image": BASE_IMAGE})()
|
||||
with patch.object(maintenance_module, "command", return_value="definition\n"), \
|
||||
patch.object(maintenance_module, "merge_snapshots", merge):
|
||||
instance.build(source)
|
||||
self.assertEqual(shell_calls, [("gitea-ci-job-image.sh", ("verify", "genarrative/gitea-project-ci:rust-cache-auto-" + SHA))])
|
||||
if build_fails:
|
||||
with self.assertRaisesRegex(RuntimeError, "assembly failed"):
|
||||
instance.build(source)
|
||||
else:
|
||||
instance.build(source)
|
||||
expected_calls = [("gitea-ci-job-image.sh", ("build",))] if rebuild else []
|
||||
if not build_fails:
|
||||
expected_calls.append(("gitea-ci-job-image.sh", ("verify", "genarrative/gitea-project-ci:rust-cache-auto-" + SHA)))
|
||||
self.assertEqual(shell_calls, expected_calls)
|
||||
self.assertIn(("create", OLD_IMAGE), docker_calls)
|
||||
self.assertIn(("rm", "--volumes", "temporary-copy-container"), docker_calls)
|
||||
self.assertEqual(docker_calls[-1], ("image", "rm", assembly_base))
|
||||
self.assertEqual(instance.state.get("bases", {}), {BASE_IMAGE: base_tag} if rebuild else {})
|
||||
if build_fails:
|
||||
self.assertIsNone(instance.state.get("candidate"))
|
||||
return
|
||||
self.assertEqual(instance.state["candidate"], IMAGE)
|
||||
self.assertEqual(instance.version(IMAGE)["run_id"], 44)
|
||||
|
||||
@@ -419,23 +475,111 @@ class GiteaCacheMaintenanceTest(unittest.TestCase):
|
||||
def test_signed_download_drops_token_and_rejects_other_origins(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
destination = self.root / "artifact.zip"
|
||||
archive = zip_bytes()
|
||||
seen = []
|
||||
target = ["https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"]
|
||||
|
||||
class Response(io.BytesIO):
|
||||
def __init__(self, value):
|
||||
super().__init__(value)
|
||||
self.headers = {"Content-Length": str(len(value))}
|
||||
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
seen.append(request)
|
||||
if not isinstance(request, str):
|
||||
raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target[0]}, None)
|
||||
return io.BytesIO(b"archive")
|
||||
return Response(archive)
|
||||
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination)
|
||||
self.assertEqual(destination.read_bytes(), b"archive")
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination, len(archive))
|
||||
self.assertEqual(destination.read_bytes(), archive)
|
||||
self.assertEqual(seen[0].get_header("Authorization"), "token test-token")
|
||||
self.assertIsInstance(seen[1], str) # signed URL request has no Authorization header
|
||||
target[0] = "https://other.example.test/download"
|
||||
with self.assertRaisesRegex(RuntimeError, "configured HTTPS Gitea origin"):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", self.root / "rejected.zip")
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", self.root / "rejected.zip", len(archive))
|
||||
self.assertFalse((self.root / "rejected.zip").exists())
|
||||
self.assertEqual(len(seen), 3) # invalid redirect is deterministic and is not retried
|
||||
|
||||
def test_download_retries_transport_and_truncated_response_with_fresh_signed_urls(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
destination = self.root / "artifact.zip"
|
||||
archive = zip_bytes(size=100000)
|
||||
seen, signed_responses = [], []
|
||||
origin_requests = 0
|
||||
target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"
|
||||
|
||||
class Response(io.BytesIO):
|
||||
def __init__(self, value):
|
||||
super().__init__(value)
|
||||
self.headers = {"Content-Length": "100000"}
|
||||
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
nonlocal origin_requests
|
||||
seen.append(request)
|
||||
if not isinstance(request, str):
|
||||
origin_requests += 1
|
||||
if origin_requests == 1:
|
||||
raise maintenance_module.urllib.error.URLError("connection reset")
|
||||
raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None)
|
||||
signed_responses.append(request)
|
||||
return Response(b"x" * 512 if len(signed_responses) == 1 else archive)
|
||||
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \
|
||||
patch.object(maintenance_module.time, "sleep") as sleep:
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination, 100000)
|
||||
self.assertEqual(destination.read_bytes(), archive)
|
||||
self.assertEqual(len(signed_responses), 2)
|
||||
self.assertEqual(origin_requests, 3)
|
||||
self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)])
|
||||
|
||||
def test_download_rejects_malformed_zip_after_retries(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
destination = self.root / "artifact.zip"
|
||||
seen = []
|
||||
target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"
|
||||
|
||||
class Response(io.BytesIO):
|
||||
headers = {"Content-Length": "9"}
|
||||
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
seen.append(request)
|
||||
if not isinstance(request, str):
|
||||
raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None)
|
||||
return Response(b"not a zip")
|
||||
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \
|
||||
patch.object(maintenance_module.time, "sleep") as sleep:
|
||||
with self.assertRaisesRegex(RuntimeError, "incomplete after retries"):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination, 9)
|
||||
self.assertFalse(destination.exists())
|
||||
self.assertEqual(sum(not isinstance(item, str) for item in seen), 3)
|
||||
self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)])
|
||||
|
||||
def test_download_rejects_valid_zip_when_its_size_differs_from_metadata(self):
|
||||
api = maintenance_module.Api(self.config["api_url"], self.token)
|
||||
destination = self.root / "artifact.zip"
|
||||
archive = zip_bytes()
|
||||
seen = []
|
||||
target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"
|
||||
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
seen.append(request)
|
||||
if not isinstance(request, str):
|
||||
raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None)
|
||||
return io.BytesIO(archive)
|
||||
|
||||
with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \
|
||||
patch.object(maintenance_module.time, "sleep") as sleep:
|
||||
with self.assertRaisesRegex(RuntimeError, "incomplete after retries"):
|
||||
api.download("repos/team/project/actions/artifacts/1/zip", destination, len(archive) + 1)
|
||||
self.assertFalse(destination.exists())
|
||||
self.assertEqual(sum(not isinstance(item, str) for item in seen), 3)
|
||||
self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)])
|
||||
|
||||
def test_pending_chunk_cleanup_requires_old_terminal_master_run(self):
|
||||
instance = self.maintenance({"versions": [], "current": None, "attempts": [{"run_id": 4}]})
|
||||
|
||||
@@ -37,6 +37,16 @@ def object_path(character: str) -> str:
|
||||
return f"objects/{key[0]}/{key[1]}/{key}"
|
||||
|
||||
|
||||
def cache_object(entries, *, mode=0o100644) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||
for name, contents in entries:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = mode << 16
|
||||
bundle.writestr(info, contents)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class SnapshotMergeTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
@@ -207,6 +217,42 @@ class SnapshotMergeTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge(inputs)
|
||||
|
||||
def test_deduplicates_cache_zip_member_order_preserving_payload_and_newest_touch(self) -> None:
|
||||
path = object_path("a")
|
||||
entries = [("lib.rlib", b"compiled"), ("lib.rmeta", b"metadata"), ("stderr", b"warning")]
|
||||
first = cache_object(entries)
|
||||
second = cache_object(list(reversed(entries)))
|
||||
self.assertEqual(len(first), len(second))
|
||||
self.assertNotEqual(hashlib.sha256(first).digest(), hashlib.sha256(second).digest())
|
||||
inputs = [
|
||||
self.archive("smoke", "smoke", 45, [(path, first, 100)]),
|
||||
self.archive("lane-1", "lane-1", 45, [(path, second, 300)]),
|
||||
self.archive("lane-2", "lane-2", 45, [(path, second, 200)]),
|
||||
]
|
||||
result = self.merge(inputs)
|
||||
output = self.root / "merged" / path
|
||||
self.assertEqual((result.object_count, result.total_bytes), (1, len(first)))
|
||||
self.assertEqual(output.read_bytes(), first)
|
||||
self.assertEqual(output.stat().st_mtime_ns, 300)
|
||||
|
||||
def test_rejects_cache_zip_payload_or_permissions_conflicts(self) -> None:
|
||||
path = object_path("a")
|
||||
first = cache_object([("lib.rlib", b"one"), ("stderr", b"err")])
|
||||
variants = {
|
||||
"payload": cache_object([("stderr", b"err"), ("lib.rlib", b"two")]),
|
||||
"permissions": cache_object([("stderr", b"err"), ("lib.rlib", b"one")], mode=0o100755),
|
||||
}
|
||||
for name, second in variants.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(len(first), len(second))
|
||||
inputs = [
|
||||
self.archive("first", "smoke", 45, [(path, first, 100)]),
|
||||
self.archive("second", "lane-1", 45, [(path, second, 200)]),
|
||||
]
|
||||
with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"):
|
||||
self.merge(inputs)
|
||||
self.assertFalse((self.root / "merged").exists())
|
||||
|
||||
def test_rejects_delta_that_conflicts_with_inherited_key(self) -> None:
|
||||
path = object_path("f")
|
||||
self.base_object(path, b"base", 1)
|
||||
|
||||
@@ -1063,15 +1063,15 @@ mod tests {
|
||||
let user = seed_phone_user_with_password(&state, "13800138195", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &user, "sess_game_distribution_publish_gate");
|
||||
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
|
||||
// 无 gate 行时默认开放:已登录作者拿到 true,匿名仍为 false。
|
||||
let mut cases = vec![(vec![], true)];
|
||||
// 灰度默认关闭:无 gate 行、enabled=false、rollout 0 都拿不到入口。
|
||||
let mut cases = vec![(vec![], false)];
|
||||
cases.push((vec![gate.clone()], false));
|
||||
gate.allow_user_ids = vec![user.id.clone()];
|
||||
cases.push((vec![gate.clone()], true));
|
||||
gate.deny_user_ids = vec![user.id.clone()];
|
||||
cases.push((vec![gate.clone()], false));
|
||||
gate.enabled = false;
|
||||
cases.push((vec![gate.clone()], true));
|
||||
cases.push((vec![gate.clone()], false));
|
||||
gate.enabled = true;
|
||||
gate.allow_user_ids.clear();
|
||||
gate.deny_user_ids.clear();
|
||||
@@ -1361,6 +1361,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_distribution_publish_open_ignores_author_allowlist_for_activation() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
// 默认关闭:作者判定与总开关都为 false。
|
||||
assert!(
|
||||
!state
|
||||
.is_game_distribution_publish_enabled_for_user(None)
|
||||
.await
|
||||
.expect("author decision")
|
||||
);
|
||||
assert!(
|
||||
!state
|
||||
.is_game_distribution_publish_open()
|
||||
.await
|
||||
.expect("open decision")
|
||||
);
|
||||
|
||||
// 开启但只放白名单作者:作者判定限白名单,管理员激活按总开关放行。
|
||||
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
|
||||
gate.enabled = true;
|
||||
gate.rollout_percent = 0;
|
||||
gate.allow_user_ids = vec!["user-allowlisted".to_string()];
|
||||
state.set_test_feature_gate_config(vec![gate]);
|
||||
assert!(
|
||||
state
|
||||
.is_game_distribution_publish_enabled_for_user(Some("user-allowlisted"))
|
||||
.await
|
||||
.expect("allowlisted author decision"),
|
||||
"白名单作者应拿到发布入口"
|
||||
);
|
||||
assert!(
|
||||
!state
|
||||
.is_game_distribution_publish_enabled_for_user(Some("user-other"))
|
||||
.await
|
||||
.expect("other author decision"),
|
||||
"白名单外作者不应拿到发布入口"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.is_game_distribution_publish_open()
|
||||
.await
|
||||
.expect("open decision"),
|
||||
"灰度开启后管理员激活新版本不应被作者白名单挡住"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_distribution_publish_switch_blocks_writes_but_keeps_reads_and_allowlist() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -1390,22 +1436,37 @@ mod tests {
|
||||
.expect("request should build")
|
||||
};
|
||||
|
||||
// 默认没有 gate 行:写入进入业务,不能被发布开关拦下。
|
||||
let open = app
|
||||
// 灰度默认关闭:没有 gate 行时发布写入必须 503 + 专用错误码。
|
||||
let default_blocked = app
|
||||
.clone()
|
||||
.oneshot(publish_request())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_ne!(
|
||||
open.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"默认状态不应拦截发布"
|
||||
assert_eq!(default_blocked.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let default_payload = read_json_response(default_blocked).await;
|
||||
assert_eq!(
|
||||
default_payload["error"]["code"],
|
||||
"GAME_DISTRIBUTION_PUBLISH_DISABLED"
|
||||
);
|
||||
|
||||
// 运营收紧到 rollout 0 且无白名单:作者写入 503 + 专用错误码。
|
||||
state.set_test_feature_gate_config(vec![test_feature_gate(
|
||||
module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY,
|
||||
)]);
|
||||
// gate 行 enabled=false 同样未开放。
|
||||
let mut disabled_gate =
|
||||
test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
|
||||
disabled_gate.enabled = false;
|
||||
state.set_test_feature_gate_config(vec![disabled_gate]);
|
||||
let disabled = app
|
||||
.clone()
|
||||
.oneshot(publish_request())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
// 开启但 rollout 0 且无白名单:仍然拦截。
|
||||
let mut zero_rollout =
|
||||
test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
|
||||
zero_rollout.enabled = true;
|
||||
zero_rollout.rollout_percent = 0;
|
||||
state.set_test_feature_gate_config(vec![zero_rollout]);
|
||||
let blocked = app
|
||||
.clone()
|
||||
.oneshot(publish_request())
|
||||
@@ -1433,6 +1494,7 @@ mod tests {
|
||||
|
||||
// 白名单内用户仍可发布(灰度放行)。
|
||||
let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY);
|
||||
gate.enabled = true;
|
||||
gate.allow_user_ids = vec![user.id.clone()];
|
||||
state.set_test_feature_gate_config(vec![gate]);
|
||||
let allowed = app
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user