Merge remote-tracking branch 'origin/master' into feat/msg-queue-to-rust
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 1m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m45s
Project CI / Frontend tests (pull_request) Successful in 4m25s
Project CI / Repository checks (pull_request) Successful in 4m34s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Backend tests (pull_request) Successful in 7m11s
Project CI / Native shell tests (pull_request) Successful in 7m50s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 1m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m45s
Project CI / Frontend tests (pull_request) Successful in 4m25s
Project CI / Repository checks (pull_request) Successful in 4m34s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Backend tests (pull_request) Successful in 7m11s
Project CI / Native shell tests (pull_request) Successful in 7m50s
This commit is contained in:
@@ -5,6 +5,8 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs';
|
||||
|
||||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||||
const source = path.resolve(process.argv[2] || '');
|
||||
@@ -287,22 +289,32 @@ try {
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(plugin, file)), file);
|
||||
}
|
||||
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
|
||||
// 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项,
|
||||
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
|
||||
const allowedNodeModules = (file) =>
|
||||
file === 'game-runtime/node/node_modules' ||
|
||||
file === 'game-runtime/node/node_modules/npm' ||
|
||||
file.startsWith('game-runtime/node/node_modules/npm/');
|
||||
const claudeAgentRoot = path.join(resources, 'claude-agent');
|
||||
const claudeAgentSdk = path.join(
|
||||
claudeAgentRoot,
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk',
|
||||
);
|
||||
const claudeAgentBinaryPackage = `claude-agent-sdk-darwin-${architecture === 'arm64' ? 'arm64' : 'x64'}`;
|
||||
const claudeAgentBinary = path.join(
|
||||
claudeAgentRoot,
|
||||
'node_modules/@anthropic-ai',
|
||||
claudeAgentBinaryPackage,
|
||||
'claude',
|
||||
);
|
||||
for (const required of [
|
||||
path.join(claudeAgentRoot, 'index.mjs'),
|
||||
path.join(claudeAgentSdk, 'sdk.mjs'),
|
||||
claudeAgentBinary,
|
||||
]) {
|
||||
assert.ok(fs.existsSync(required), required);
|
||||
}
|
||||
// 随包只允许 Node runtime 的 npm 与 Claude Agent SDK sidecar 这两棵
|
||||
// node_modules 子树;任何其它 node_modules 都是构建残留或不可控依赖。
|
||||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||||
assert.ok(
|
||||
!packageFiles.some(
|
||||
(file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
|
||||
),
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles(packageFiles),
|
||||
[],
|
||||
'安装包包含禁止资源',
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* macOS 安装包内容策略。
|
||||
*
|
||||
* `check-macos-bundle.mjs` 会扫描随包 resources。Node runtime 与 Claude Agent SDK
|
||||
* sidecar 都需要 `node_modules` 形式的目录,但其它构建残留或第三方依赖不能被
|
||||
* 静默带入安装包,因此这里只放行两棵明确的生产子树。
|
||||
*/
|
||||
|
||||
const NODE_RUNTIME_NODE_MODULES = 'game-runtime/node/node_modules';
|
||||
const CLAUDE_AGENT_NODE_MODULES = 'claude-agent/node_modules';
|
||||
const CLAUDE_AGENT_SCOPE = `${CLAUDE_AGENT_NODE_MODULES}/@anthropic-ai`;
|
||||
|
||||
const CLAUDE_AGENT_PACKAGE_PATTERN =
|
||||
/^(?:claude-agent-sdk|claude-agent-sdk-darwin-(?:arm64|x64))(?:\/.*)?$/u;
|
||||
|
||||
function isAllowedClaudeAgentNodeModulesPath(file) {
|
||||
if (file === CLAUDE_AGENT_NODE_MODULES || file === CLAUDE_AGENT_SCOPE) {
|
||||
return true;
|
||||
}
|
||||
if (!file.startsWith(`${CLAUDE_AGENT_SCOPE}/`)) {
|
||||
return false;
|
||||
}
|
||||
return CLAUDE_AGENT_PACKAGE_PATTERN.test(
|
||||
file.slice(`${CLAUDE_AGENT_SCOPE}/`.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAllowedBundledNodeModulesPath(file) {
|
||||
return (
|
||||
file === NODE_RUNTIME_NODE_MODULES ||
|
||||
file === `${NODE_RUNTIME_NODE_MODULES}/npm` ||
|
||||
file.startsWith(`${NODE_RUNTIME_NODE_MODULES}/npm/`) ||
|
||||
isAllowedClaudeAgentNodeModulesPath(file)
|
||||
);
|
||||
}
|
||||
|
||||
export function listForbiddenBundledResourceFiles(files) {
|
||||
return files.filter(
|
||||
(file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/u.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/u.test(file) &&
|
||||
!isAllowedBundledNodeModulesPath(file)),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs';
|
||||
import {
|
||||
assertMacosAppMatchesChannelIdentity,
|
||||
assertManifestArtifactMatchesExpected,
|
||||
@@ -8,6 +9,42 @@ import {
|
||||
readMacosAppInfoIdentity,
|
||||
} from './macos-release-identity.mjs';
|
||||
|
||||
test('allows only the production node_modules subtrees in the macOS bundle', () => {
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles([
|
||||
'game-runtime/node/node_modules',
|
||||
'game-runtime/node/node_modules/npm/bin/npm-cli.js',
|
||||
'claude-agent/node_modules',
|
||||
'claude-agent/node_modules/@anthropic-ai',
|
||||
'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude',
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles([
|
||||
'claude-agent/node_modules/unexpected/index.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs',
|
||||
'plugins/agc-cocos-editor/node_modules/leftover/index.mjs',
|
||||
'game-runtime/node/.env.local',
|
||||
'claude-agent/auth.json',
|
||||
'claude-agent/target/debug/claude',
|
||||
'claude-agent/tool.exe',
|
||||
'claude-agent/tool.dll',
|
||||
]),
|
||||
[
|
||||
'claude-agent/node_modules/unexpected/index.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs',
|
||||
'plugins/agc-cocos-editor/node_modules/leftover/index.mjs',
|
||||
'game-runtime/node/.env.local',
|
||||
'claude-agent/auth.json',
|
||||
'claude-agent/target/debug/claude',
|
||||
'claude-agent/tool.exe',
|
||||
'claude-agent/tool.dll',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
const DEV_IDENTITY = {
|
||||
productName: '陶泥儿开发版',
|
||||
identifier: 'world.genarrative.ai-game-creator',
|
||||
|
||||
@@ -47,79 +47,6 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# 清智创游官网独立静态站点:根页面位于 /home/index.html,API 仍由当前 api-server 提供。
|
||||
# 该 vhost 不依赖主站 SERVER_ALIASES,避免域名落入静态站点后对 /api 返回 Nginx 404。
|
||||
server {
|
||||
listen 80;
|
||||
server_name tsingnovagames.com www.tsingnovagames.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name tsingnovagames.com www.tsingnovagames.com;
|
||||
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||
error_log /var/log/nginx/genarrative.error.log warn;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/genarrative.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/genarrative.example.com/privkey.pem;
|
||||
|
||||
root /srv/genarrative/web;
|
||||
index index.html;
|
||||
|
||||
include /etc/nginx/snippets/genarrative-maintenance.conf;
|
||||
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
client_max_body_size 210m;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location = / {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files /home/index.html =404;
|
||||
}
|
||||
|
||||
location ^~ /home/ {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
error_page 404 /404.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name genarrative.example.com;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 清智创游官网下载接口:此 snippet 只在现役 tsingnova-games HTTPS server 中 include。
|
||||
# 依赖主 Nginx 配置已声明的 genarrative_api、限流区和维护状态变量。
|
||||
location = /api/client-downloads {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
limit_conn_status 429;
|
||||
limit_req_status 429;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
proxy_set_header Cookie "";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 15s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header X-Accel-Buffering no always;
|
||||
}
|
||||
@@ -112,19 +112,6 @@
|
||||
},
|
||||
"docs": ["`/assets/*`"]
|
||||
},
|
||||
{
|
||||
"id": "official_home_assets",
|
||||
"samplePath": "/home/assets/index.js",
|
||||
"expect": {
|
||||
"kind": "static",
|
||||
"root": "web",
|
||||
"mode": "exact"
|
||||
},
|
||||
"nginx": {
|
||||
"production": ["location ^~ /home/", "try_files $uri $uri/ =404;"]
|
||||
},
|
||||
"docs": ["/home/"]
|
||||
},
|
||||
{
|
||||
"id": "generic_api_proxy",
|
||||
"samplePath": "/api/assets/history",
|
||||
|
||||
@@ -59,8 +59,17 @@
|
||||
- **处理**:两侧都补了保留上限。服务端:`production-api-deploy.sh` 新增 `--keep-releases`(默认 `2`),发布成功后保留 `current` 目标与最近 1 个历史 release,只删除同时含 `api-server` 或 `web` 标记的旧目录,清理失败只告警、不改变发布结论。CI 侧:`Genarrative-Api-Deploy` / `Genarrative-Web-Deploy` / `Genarrative-Stdb-Module-Publish` 在各自部署 / 发布步骤成功后只保留最近 2 个 `build/<version>/`,失败时不清理以便诊断和重跑。`npm run check:production-api-deploy` 增加默认值、显式值和非法值三类夹具,`npm run check:production-ops` 增加对应合同。
|
||||
- **不要踩的坑**:直接按 mtime 排序删除会连带删掉发布根目录下不属于发布产物的目录(例如 `dev-mcp-host-*`),必须用 `api-server`/`web` 标记筛选;脚本里的 `mv -T`、`find -printf` 都是 GNU 语义,`npm run check:production-api-deploy` 需要 `sha256sum` 和 `/usr/bin/cp`,Windows 本地跑不了,只在 Linux CI / Linux 检出上有效(本地最低限度用 `bash -n` + `npm run check:production-ops`)。
|
||||
- **写 Jenkins 内联 shell 的两个坑**:① Groovy 会处理 `sh '''…'''` / `sh """…"""` 里的反斜杠转义——`\n` 到 shell 手上会变成真实换行(`Jenkinsfile.production-stdb-module-build` 里必须写 `printf "\\r"` 就是同一件事),所以内联片段要么完全不用 `\`,要么写 `\\`;`"""` 是 GString,shell 变量必须写 `\$name`,而 `'''` 不插值、保持 `${name}`。② `set -euo pipefail` 下 `ls build/*/` 在 glob 不匹配时会因 pipefail 把整个部署步骤判失败(实测:`build/` 为空时清理步骤会把一次成功发布判成失败),必须用 `if [ -d build ]` 守卫 + `|| true` 兜底。
|
||||
- **GString 里的命令替换同样要转义**:`sh """…"""` 内的 shell 命令替换必须写 `\$(...)`。写成裸 `$(...)` 时 Jenkins/Groovy 会在加载 Jenkinsfile 时直接报 `illegal string body character after dollar sign`,构建不会进入任何 stage;`npm run check:production-ops` 现已钉住 Stdb Publish 的暂存清理命令。
|
||||
- **关联**:`scripts/deploy/production-api-deploy.sh`、`scripts/check-production-api-deploy.mjs`、`scripts/check-production-ops-guardrails.mjs`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`。
|
||||
|
||||
## 2026-09-30 AGC macOS 包内容门禁必须识别随包 Claude Agent SDK
|
||||
|
||||
- **现象**:`Genarrative-Agc-MacOS-Build #80` 已成功生成并核对 `陶泥儿开发版.app` 的身份与版本,却在 `check-macos-bundle.mjs` 的 resources 白名单断言处失败,Jenkins 只打印一条无文件名的 `AssertionError`。
|
||||
- **原因**:新增 Claude Agent SDK sidecar 后,构建会把 SDK 与匹配平台的 Claude runtime 放到 `claude-agent/node_modules/@anthropic-ai/`;macOS 包内容门禁仍按旧口径把除 Node runtime npm 以外的所有 `node_modules` 都判为禁止。
|
||||
- **处理**:把包内容策略抽成纯函数,只放行 `game-runtime/node/node_modules/npm` 和 `claude-agent/node_modules/@anthropic-ai/claude-agent-sdk[-darwin-*]` 两棵明确子树;同时显式要求 sidecar 入口、SDK 与平台 runtime 存在,并让违规时输出具体相对路径。
|
||||
- **验证**:`node --test apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`;macOS 实包再由 `check-macos-bundle.mjs` 复核。
|
||||
- **关联**:`apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs`、`apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs`、`apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs`、`apps/ai-game-creator-shell/src-tauri/build.rs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`。
|
||||
|
||||
## 2026-09-29 dev 上的 JNLP inbound agent 是历史残留,会在死端口上无限重连刷爆 syslog
|
||||
|
||||
- **现象**:dev 的 `/var/log/syslog` 约 250MB/天,内容是 `jenkins-inbound-agent-start[pid]` 反复输出指向 `http://127.0.0.1:18080/tcpSlaveAgentListener/` 的 `Connection refused` 完整栈(2.6 天 61 万行,其中 `genarrative-release-deploy-01` 占 53 万行)。
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
|
||||
### 清智创游官网独立域名路由
|
||||
|
||||
清智创游官网 `tsingnovagames.com` / `www.tsingnovagames.com` 的首页静态资源位于同一 Web 根目录的 `/home/`,首页“游戏引擎”入口按同源请求 `/api/client-downloads`。生产 Nginx 必须让这两个域名进入包含 `/api` 反代的主站 `server block`,并把该域名根路径重写到 `/home/`;否则页面可以打开,但接口会被静态站点直接返回 HTML 404,前端显示“暂时无法获取下载信息,请稍后重试”。
|
||||
清智创游官网 `tsingnovagames.com` / `www.tsingnovagames.com` 使用独立的 Nginx 配置 `/etc/nginx/conf.d/tsingnova-games.conf`,静态根目录是 `/srv/genarrative/corporate-site-current`;主站模板 `deploy/nginx/genarrative.conf` 不应重复声明这两个域名。官网首页按同源请求 `/api/client-downloads`,因此应在现役官网 HTTPS `server` 中 include `deploy/nginx/snippets/tsingnova-client-downloads.conf`,只代理这个精确路径到 `genarrative_api`,不要把整个 `/api` 交给官网静态站点兜底。
|
||||
|
||||
`Genarrative-Server-Provision` 在 `SERVER_NAME=genarrative.world`(或 `www.genarrative.world`)且使用 `production-https` 时会自动补入这两个官网别名,并渲染 `/home/` 根路径兼容规则。修改后需重新执行一次正式服务器配置发布并 reload Nginx,不要只重启 api-server:
|
||||
线上修复或排障后必须确认的是**生效配置**而不是仓库模板:
|
||||
|
||||
```bash
|
||||
sudo nginx -T | grep -n -A45 -B5 'server_name tsingnovagames.com'
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
curl -fsS -D ~/data/tmp/tsingnova-client-download.headers \
|
||||
https://tsingnovagames.com/api/client-downloads \
|
||||
-o ~/data/tmp/tsingnova-client-download.json
|
||||
@@ -18,7 +21,7 @@ jq . ~/data/tmp/tsingnova-client-download.json
|
||||
grep -i '^cache-control: no-store' ~/data/tmp/tsingnova-client-download.headers
|
||||
```
|
||||
|
||||
验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若接口仍返回 Nginx 404,优先检查 `nginx -T` 中 `server_name` 是否包含上述两个域名以及 `/api` location 是否指向 `genarrative_api`。
|
||||
验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若 API 直连 `http://127.0.0.1:8082/api/client-downloads` 是 `200`、官网域名仍是 `404`,说明官网 vhost 没有 include 下载 snippet,不能靠 reload 旧配置解决。
|
||||
|
||||
## 构建回归的隔离与发布文件权限
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ pipeline {
|
||||
# 只保留最近 2 个 build/<version> 暂存:每次发布都用 copyArtifacts 重新落一份,
|
||||
# 历史暂存不参与发布、只占目标机磁盘;发布失败时不清理,便于诊断和重跑。
|
||||
if [ -d build ]; then
|
||||
stale_list="$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)"
|
||||
stale_list="\$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)"
|
||||
while IFS= read -r stale_staging; do
|
||||
[ -n "\$stale_staging" ] || continue
|
||||
echo "[staging-cleanup] 清理历史构建暂存: \$stale_staging"
|
||||
|
||||
@@ -458,6 +458,13 @@ const checks = [
|
||||
reason:
|
||||
'Stdb Publish Job 必须清理历史 build/<version> 暂存,避免目标机被 copyArtifacts 暂存撑满。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes:
|
||||
'stale_list="\\$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)"',
|
||||
reason:
|
||||
'Stdb Publish 的 GString 内联 shell 必须把命令替换的 $ 写成 \\$;否则 Jenkinsfile 会在进入 stage 前因 Groovy 编译失败。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
|
||||
@@ -3,26 +3,97 @@ import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const productionNginx = readFileSync('deploy/nginx/genarrative.conf', 'utf8');
|
||||
const provisionScript = readFileSync(
|
||||
'scripts/jenkins-server-provision.sh',
|
||||
const clientDownloadsSnippet = readFileSync(
|
||||
'deploy/nginx/snippets/tsingnova-client-downloads.conf',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function extractServerBlocks(source: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const serverStart = /\bserver\s*\{/gu;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = serverStart.exec(source)) !== null) {
|
||||
const openBrace = source.indexOf('{', match.index);
|
||||
let depth = 0;
|
||||
let quote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
let inComment = false;
|
||||
|
||||
for (let index = openBrace; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
|
||||
if (inComment) {
|
||||
if (character === '\n') {
|
||||
inComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === '\\') {
|
||||
escaped = true;
|
||||
} else if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '#') {
|
||||
inComment = true;
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === '{') {
|
||||
depth += 1;
|
||||
} else if (character === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
blocks.push(source.slice(match.index, index + 1));
|
||||
serverStart.lastIndex = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
describe('清智创游官网引擎下载路由', () => {
|
||||
it('生产 Nginx 为官网域名提供独立 vhost 和 API 反代', () => {
|
||||
expect(productionNginx).toContain(
|
||||
'server_name tsingnovagames.com www.tsingnovagames.com;',
|
||||
it('主站模板不重复声明官网独立域名,下载路由由现役官网 vhost 单独维护', () => {
|
||||
expect(productionNginx).not.toContain('tsingnovagames.com');
|
||||
expect(clientDownloadsSnippet).toContain(
|
||||
'location = /api/client-downloads {',
|
||||
);
|
||||
expect(productionNginx).toMatch(
|
||||
/server_name tsingnovagames\.com www\.tsingnovagames\.com;[\s\S]*?location ~ \^\/api\(\?:\/\|\$\)[\s\S]*?proxy_pass http:\/\/genarrative_api;/u,
|
||||
);
|
||||
expect(productionNginx).toContain('try_files /home/index.html =404;');
|
||||
expect(productionNginx).toContain('location ^~ /home/');
|
||||
});
|
||||
|
||||
it('Server-Provision 会把正式证书路径渲染进官网 vhost', () => {
|
||||
expect(provisionScript).toContain(
|
||||
's|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g',
|
||||
it('下载 snippet 只声明精确接口并复用主站保护与 upstream', () => {
|
||||
expect(clientDownloadsSnippet).toContain(
|
||||
'location = /api/client-downloads {',
|
||||
);
|
||||
expect(clientDownloadsSnippet).not.toMatch(
|
||||
/location\s+(?:~|\^~)?\s*\/api(?:\/|\s|\{|\$)/u,
|
||||
);
|
||||
for (const directive of [
|
||||
'limit_conn genarrative_api_conn 64;',
|
||||
'limit_req zone=genarrative_api_rps burst=64 nodelay;',
|
||||
'limit_conn_status 429;',
|
||||
'limit_req_status 429;',
|
||||
'if ($genarrative_maintenance) {',
|
||||
'proxy_pass http://genarrative_api;',
|
||||
'proxy_set_header Cookie "";',
|
||||
'proxy_set_header Authorization "";',
|
||||
'proxy_connect_timeout 3s;',
|
||||
'proxy_read_timeout 15s;',
|
||||
'proxy_send_timeout 15s;',
|
||||
'proxy_buffering off;',
|
||||
'proxy_cache off;',
|
||||
'add_header X-Accel-Buffering no always;',
|
||||
]) {
|
||||
expect(clientDownloadsSnippet).toContain(directive);
|
||||
}
|
||||
expect(clientDownloadsSnippet).not.toMatch(
|
||||
/^\s*add_header\s+Cache-Control\b/mu,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getPlatformActionButtonClassName } from '@genarrative/shared/components';
|
||||
import {
|
||||
getPlatformActionButtonClassName,
|
||||
type PlatformActionButtonSize,
|
||||
type PlatformActionButtonTone,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Download, Monitor } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -10,6 +14,16 @@ import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试';
|
||||
|
||||
type ClientDownloadEntryProps = {
|
||||
/** 按钮文案,默认「下载客户端」。 */
|
||||
label?: string;
|
||||
tone?: PlatformActionButtonTone;
|
||||
size?: PlatformActionButtonSize;
|
||||
/** 窄屏只留图标:平台顶栏默认开启,页面级引导入口传 false 保留完整文案。 */
|
||||
iconOnlyOnNarrowScreens?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type DownloadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; release: ClientDownloadResponse }
|
||||
@@ -22,7 +36,13 @@ function downloadLabel(download: ClientDownloadResponse['downloads'][number]) {
|
||||
return `macOS(${download.architecture === 'aarch64' ? 'Apple Silicon' : 'Intel'})`;
|
||||
}
|
||||
|
||||
export function ClientDownloadEntry() {
|
||||
export function ClientDownloadEntry({
|
||||
label = '下载客户端',
|
||||
tone = 'secondary',
|
||||
size = 'sm',
|
||||
iconOnlyOnNarrowScreens = true,
|
||||
className,
|
||||
}: ClientDownloadEntryProps = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<DownloadState>({ status: 'loading' });
|
||||
@@ -72,19 +92,31 @@ export function ClientDownloadEntry() {
|
||||
return (
|
||||
<>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
tone={tone}
|
||||
size={size}
|
||||
shape="pill"
|
||||
aria-label="下载客户端"
|
||||
title="下载客户端"
|
||||
className="min-h-11 shrink-0 gap-0 whitespace-nowrap max-sm:h-11 max-sm:w-11 max-sm:px-0 sm:gap-2 sm:px-4"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={[
|
||||
'shrink-0 whitespace-nowrap',
|
||||
iconOnlyOnNarrowScreens
|
||||
? 'min-h-11 gap-0 max-sm:h-11 max-sm:w-11 max-sm:px-0 sm:gap-2 sm:px-4'
|
||||
: 'gap-2',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
setState({ status: 'loading' });
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">下载客户端</span>
|
||||
<span
|
||||
className={iconOnlyOnNarrowScreens ? 'hidden sm:inline' : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</PlatformActionButton>
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
|
||||
|
||||
type GamePublishClientGuideProps = HTMLAttributes<HTMLElement>;
|
||||
|
||||
/** 悬浮引导卡片:把还没有游戏包的作者引到客户端。 */
|
||||
export function GamePublishClientGuide({
|
||||
className,
|
||||
...props
|
||||
}: GamePublishClientGuideProps) {
|
||||
return (
|
||||
<section
|
||||
className={`flex w-fit max-w-full flex-none flex-col items-center gap-3
|
||||
rounded-2xl border px-[1.15rem] py-[0.9rem] text-center
|
||||
[background:linear-gradient(135deg,color-mix(in_srgb,var(--platform-action-accent,#c7653d)_11%,var(--platform-panel-fill))_0%,color-mix(in_srgb,var(--platform-action-accent,#c7653d)_4%,var(--platform-panel-fill))_100%)]
|
||||
border-[color-mix(in_srgb,var(--platform-action-accent,#c7653d)_26%,transparent)]
|
||||
[box-shadow:0_0.55rem_1.1rem_color-mix(in_srgb,var(--platform-action-accent,#c7653d)_12%,transparent)]
|
||||
${className ?? ''}`}
|
||||
{...props}
|
||||
>
|
||||
<strong className="text-[0.82rem] text-(--platform-text-strong)">
|
||||
还没有游戏包?
|
||||
</strong>
|
||||
<ClientDownloadEntry
|
||||
label="下载客户端制作游戏"
|
||||
tone="accentSoft"
|
||||
size="sm"
|
||||
iconOnlyOnNarrowScreens={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default GamePublishClientGuide;
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
resolveGamePublishImagePreview,
|
||||
uploadGamePublishImageAsset,
|
||||
} from './gamePublishAssets';
|
||||
import { GamePublishClientGuide } from './GamePublishClientGuide';
|
||||
import {
|
||||
clearPublishDraft,
|
||||
type GamePublishDraft,
|
||||
@@ -711,12 +712,12 @@ export function GamePublishPage({
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<div className="game-toolbar [flex-wrap:nowrap] max-[720px]:[flex-wrap:wrap]">
|
||||
<div className="game-toolbar__title [min-width:0]">
|
||||
<span className="game-eyebrow">
|
||||
{updateGameId ? '发布新版本' : '发布游戏'}
|
||||
</span>
|
||||
<h2>
|
||||
<h2 className="[white-space:nowrap] max-[720px]:[white-space:normal]">
|
||||
{updateGameId
|
||||
? updateContext
|
||||
? `为《${updateContext.title}》发布 v${updateContext.versionNumber + 1}`
|
||||
@@ -724,6 +725,9 @@ export function GamePublishPage({
|
||||
: '上传可在线游玩的网页游戏'}
|
||||
</h2>
|
||||
</div>
|
||||
{!updateGameId ? (
|
||||
<GamePublishClientGuide className="ml-auto max-[720px]:ml-0" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
Reference in New Issue
Block a user