Merge remote-tracking branch 'refs/remotes/origin/master' into refactor/extract-dep-from-ref-inputer
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m30s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Successful in 7m34s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m9s
Project CI / Repository checks (pull_request) Successful in 3m36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m30s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Successful in 7m34s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m9s
Project CI / Repository checks (pull_request) Successful in 3m36s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -648,6 +648,32 @@ function readHeadCommit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行;
|
||||
* 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。
|
||||
*/
|
||||
function commitMessageTitle(message) {
|
||||
return String(message ?? '')
|
||||
.split(/\r?\n/u, 1)[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */
|
||||
function parseReleaseCommitLog(output) {
|
||||
return output
|
||||
.split('\0')
|
||||
.map((record) => record.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((record) => {
|
||||
const separator = record.indexOf('\t');
|
||||
if (separator < 0) return null;
|
||||
const sha = record.slice(0, separator);
|
||||
const subject = commitMessageTitle(record.slice(separator + 1));
|
||||
return sha && subject ? { sha, subject } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次发布到本次之间的客户端相关提交。
|
||||
*
|
||||
@@ -675,8 +701,9 @@ export function collectReleaseCommits(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
'--format=%h%x09%s',
|
||||
'--format=%h%x09%B',
|
||||
`${previousCommit}..${headCommit}`,
|
||||
'--',
|
||||
...paths,
|
||||
@@ -686,28 +713,22 @@ export function collectReleaseCommits(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
return parseReleaseCommitLog(output);
|
||||
}
|
||||
|
||||
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
|
||||
/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */
|
||||
export function formatReleaseNotes(
|
||||
commits,
|
||||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||||
) {
|
||||
if (!commits || commits.length === 0) return '';
|
||||
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
|
||||
const lines = commits.slice(0, limit).map(({ subject }) => {
|
||||
const title = commitMessageTitle(subject);
|
||||
const trimmed =
|
||||
subject.length > subjectLength
|
||||
? `${subject.slice(0, subjectLength - 1)}…`
|
||||
: subject;
|
||||
return `- ${trimmed}(${sha})`;
|
||||
title.length > subjectLength
|
||||
? `${title.slice(0, subjectLength - 1)}…`
|
||||
: title;
|
||||
return `- ${trimmed}`;
|
||||
});
|
||||
if (commits.length > limit) {
|
||||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||||
@@ -726,20 +747,21 @@ export function collectRecentReleaseCommits({
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
`-n${limit}`,
|
||||
'--format=%h%x09%B',
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const commits = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
const commits = parseReleaseCommitLog(output);
|
||||
return commits.length > 0 ? commits : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
|
||||
test('no-bundle smoke skips version writes and manifest generation', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () =>
|
||||
buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
@@ -1075,20 +1077,17 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade
|
||||
assert.ok(source.includes('\n dryRun,\n'));
|
||||
});
|
||||
|
||||
test('release notes list client commits with short sha and bound their size', () => {
|
||||
test('release notes list client commit subjects only and bound their size', () => {
|
||||
const notes = formatReleaseNotes([
|
||||
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
|
||||
{ sha: '55af6014', subject: '修'.repeat(120) },
|
||||
]);
|
||||
const lines = notes.split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
|
||||
const truncatedSubject = lines[1]
|
||||
.replace(/^- /u, '')
|
||||
.replace(/(55af6014)$/u, '');
|
||||
assert.equal(lines[0], '- 客户端更新切换到官方更新插件');
|
||||
const truncatedSubject = lines[1].replace(/^- /u, '');
|
||||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||||
assert.match(truncatedSubject, /…$/u);
|
||||
assert.match(lines[1], /(55af6014)$/u);
|
||||
|
||||
const many = formatReleaseNotes(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
@@ -1097,6 +1096,15 @@ test('release notes list client commits with short sha and bound their size', ()
|
||||
})),
|
||||
);
|
||||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||||
assert.equal(
|
||||
formatReleaseNotes([
|
||||
{
|
||||
sha: 'ignored',
|
||||
subject: '提交标题\n不应展示的说明一\n不应展示的说明二',
|
||||
},
|
||||
]),
|
||||
'- 提交标题',
|
||||
);
|
||||
assert.equal(formatReleaseNotes([]), '');
|
||||
assert.equal(formatReleaseNotes(null), '');
|
||||
});
|
||||
@@ -1120,8 +1128,17 @@ test('release commits cover only client paths and skip merge commits', () => {
|
||||
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
|
||||
'fn main() {}\n',
|
||||
);
|
||||
const commitMessagePath = path.join(
|
||||
directory,
|
||||
'.git',
|
||||
'commit-message.txt',
|
||||
);
|
||||
writeFileSync(
|
||||
commitMessagePath,
|
||||
'客户端:新增更新插件接入\n补充更新插件接入的详细说明\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
|
||||
git('commit', '--quiet', '-F', commitMessagePath);
|
||||
|
||||
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
@@ -1147,6 +1164,7 @@ test('release commits cover only client paths and skip merge commits', () => {
|
||||
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
|
||||
assert.ok(commits, '应能在临时仓库里收集提交');
|
||||
const subjects = commits.map((entry) => entry.subject);
|
||||
// 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。
|
||||
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
|
||||
assert.deepEqual(subjects, [
|
||||
'客户端:侧分支改动',
|
||||
|
||||
@@ -138,7 +138,13 @@ install -m 600 deploy/container/gitea-ci-cache.config.example.json /etc/genarrat
|
||||
docker compose -f deploy/container/gitea-runner-fetch-gate.compose.yml up -d
|
||||
```
|
||||
|
||||
**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://gitea:3000/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致,沿用现有 job 已可达的内部 Git 通道。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。
|
||||
**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段;runner 进程的 `NO_PROXY` / `no_proxy` 必须加入 `gitea-runner-fetch-gate`。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://genarrative-station/git/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致。当前 job 通过 `--add-host=genarrative-station:172.30.0.3` 访问现有 actions gateway,job 的代理例外保留 `genarrative-station`;runner 自身能访问的 `http://gitea:3000` 不代表内层 job 也能访问。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。
|
||||
|
||||
维护器的 `clone_url` 属于宿主网络:当前 Gitea 将容器 3000 映射到宿主 3003,因此使用 `http://127.0.0.1:3003/GenarrativeAI/Genarrative.git`,避免本机完整 clone 绕公网导致 900 秒超时;不要把这个 loopback 地址传给 job。其它部署必须按实际端口核实。`api_url` 仍使用 HTTPS 公开 API 地址。
|
||||
|
||||
内层 Docker 的默认地址池必须避开外层 `gitea-actions` 的 `172.30.0.0/24` 和 egress 的 `172.31.0.0/24`。当前 `/opt/gitea-stack/config/runner-dockerd-run` 在 dockerd 参数中设置 `--default-address-pool base=10.240.0.0/16,size=24`,每个 job 获得独立 `/24`。变更在 runner 重启后生效,只影响新网络;先逐个检查并定向删除无容器引用的 `GITEA-ACTIONS-TASK-*` 遗留网络,不能全局 prune。取消或强制停止 runner 可能留下空网络,默认 Docker `/16` 地址池累积到 `172.30.0.0/16` 会截走网关流量。
|
||||
|
||||
恢复领取前,必须在内层 Docker 新建与 job 相同的 bridge,使用现役 CI Image ID、相同 host 映射和代理环境,实际执行 `genarrative-gitea-checkout` 并访问 `https://git.genarrative.world/git/api/v1/version`;同时确认新网络使用上述 `/24` 地址池。仅宿主 `git ls-remote`、runner 注册成功或 FetchTask 路由通过,均不能替代 job 网络验收。
|
||||
|
||||
不能仅改磁盘文件却不让进程加载;维护器还会检查独立 checkout URL,并确认入口实际见到了当前容器本次启动后的 FetchTask 来源 IP。此一次接入不由维护器冒险猜测空闲,也不对运行中 CI 动手。实例 API 根地址仍使用配置中的 HTTPS Gitea 地址,不能指向只支持 runner RPC 的入口。上传脚本从独立 `GENARRATIVE_GITEA_REPOSITORY_URL` 推导真实 Gitea 地址,使用本任务临时凭据调用原生 V4 API;不依赖被网关覆盖的 `GITHUB_SERVER_URL` / `ACTIONS_RUNTIME_URL`。Gitea 1.26.4 的仓库 REST 下载接口只支持 V4,不能换回 V3 上传。宿主下载只跟随同一 HTTPS Gitea origin 的签名重定向,不转发长期 Token;如配置外部对象存储直出,需另行适配下载来源。
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"api_url": "https://git.genarrative.world/git/api/v1",
|
||||
"repository": "GenarrativeAI/Genarrative",
|
||||
"repository_url": "http://gitea:3000/GenarrativeAI/Genarrative.git",
|
||||
"clone_url": "https://git.genarrative.world/git/GenarrativeAI/Genarrative.git",
|
||||
"repository_url": "http://genarrative-station/git/GenarrativeAI/Genarrative.git",
|
||||
"clone_url": "http://127.0.0.1:3003/GenarrativeAI/Genarrative.git",
|
||||
"runner_container": "gitea-runner",
|
||||
"token_file": "/etc/genarrative-ci-cache/api-token",
|
||||
"state_dir": "/var/lib/genarrative-ci-cache",
|
||||
|
||||
@@ -13,8 +13,18 @@
|
||||
- 未纳入本次:粘贴解析(未来接入点是 provider 的 `mentionToken` 与既有 `buildContentFromTextTokens`)、`resource-reference-*` CSS 类名重命名(独立机械提交)、扩展变更事件的即时失效。
|
||||
- 验证方式:定向 `npx vitest run apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx` 与受影响宿主用例、`npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`;行为零变化按「改名后芯片显示名自动刷新、打开面板前重读清单、`@`/`$` 候选与键盘交互、附件上限与失败提示文案」逐条对照核验,唯一例外是已裁决的 Skill 可见性缺陷修复。
|
||||
|
||||
## 2026-09-22 release 每日调度纳入正式 Full Build 并收集结果
|
||||
|
||||
- 背景:原 `Genarrative-Scheduled-Release-Trigger` 只处理 AGC Windows/macOS,且对下游使用 `wait: false` 后立即推进 revision;正式服务端 release 仍只能人工触发,客户端构建失败也不会回传调度器。
|
||||
- 决策:release 调度同时计算 `AGC` 与 `FullBuild` 两条 scope。服务端相关路径变化时以 `DEPLOY_TARGET=release`、`CONFIRM_RELEASE_DEPLOY_AGENT=true` 触发 `Genarrative-Full-Build-And-Deploy`;客户端相关路径变化时保持统一发号并触发 Windows/macOS release。Full Build、Windows、macOS 三路均等待结果并汇总,只有成功的 lane 才推进对应 `.jenkins-last-release-full-revision` / `.jenkins-last-release-agc-revision`,失败 lane 在下一轮单独补发,避免重复部署已成功的服务端版本。`DATABASE_BACKUP_MODE` 默认 `async`,并显式提供 `STDB_API_ROLLOUT_MODE` / approvers 以保留正式维护门禁。
|
||||
- 原因:正式 release 需要与 dev 小时调度隔离,同时不能继续依赖人工触发服务端;lane-level 成功状态可区分“已成功但另一 lane 失败”的部分发布,避免下一次调度重复发布 Full Build。
|
||||
- 影响范围:`jenkins/Jenkinsfile.scheduled-release-trigger`、`jenkins/scheduled-release-trigger-job-config.xml`、`scripts/check-production-ops-guardrails.mjs`、开发运维文档与共享开发工作流。
|
||||
- 验证方式:生产运维门禁检查 Full Build release 参数、双 scope、`wait: true` 结果聚合、三路分支和成功后才写 lane revision;并用 Jenkins 具体构建验证 Full Build/AGC 的 build number 与结果能回传到调度 Job。
|
||||
|
||||
## 2026-09-22 AGC release 增加每日调度,dev 调度保持双平台
|
||||
|
||||
> 已被上一条决策取代:release 调度现已纳入正式服务端 Full Build。
|
||||
|
||||
- 背景:原有 `Genarrative-Scheduled-Revision-Trigger` 每小时跟随 revision 发布 dev 客户端,但没有对应的 release 渠道自动入口;Mac 节点此前已纳入小时 dev 调度,需要避免新增 release 调度时再退回 Windows-only。
|
||||
- 决策:新增 `Genarrative-Scheduled-Release-Trigger`,每天 04:00 检查 `SOURCE_BRANCH`,使用独立的 `.jenkins-last-release-revision` 与客户端相关路径白名单;上一轮 release 调度后有客户端变更时,先经 `Genarrative-Agc-Global-Version-Issue` 发统一总号,再以同一固定 `COMMIT_HASH` 触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 的 `AGC_UPDATE_CHANNEL=release` 分区,Mac 继续带 `SKIP_IF_SUPERSEDED=true`。小时 dev 调度保持同时触发 Windows 与 macOS dev。
|
||||
- 原因:release 与 dev 是不同渠道和发布节奏,不能靠同一个小时 Job 隐式切换;独立 Job 能分别去重、记录状态和审计发号。release 调度不触发 Full Build,避免把桌面客户端发布与线上全栈部署绑定。
|
||||
|
||||
@@ -92,7 +92,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
|
||||
|
||||
## Jenkins 定时版本调度
|
||||
|
||||
定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 处理 AGC release 渠道,只在上一轮 release 调度后出现客户端相关路径变化时,经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 按服务端与客户端两条独立 scope 处理 release,服务端变化时用 `DEPLOY_TARGET=release` 触发正式 Full Build,客户端变化时经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。两条调度都等待并汇总下游,按成功 lane 推进 revision;失败 lane 下一轮单独补发。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Gitea CI 依赖闭合
|
||||
|
||||
@@ -100,6 +100,8 @@ Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一
|
||||
|
||||
修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS`;`test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request` 的 `method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。
|
||||
|
||||
Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-gate:8080`;内层 job 的 checkout/上传走映射到 `172.30.0.3` 的 `http://genarrative-station/git`;宿主专用 clone 走 `http://127.0.0.1:3003`。不要把 runner 可达的 `gitea:3000` 配给 job。内层 Docker 使用 `10.240.0.0/16`、每 job `/24` 的默认地址池,避开外层 `172.30/172.31` 网段;恢复领取前必须在真实 job 网络里验证 checkout 与 Gitea API,不能只验证 FetchTask。具体配置与遗留空网络处理见 `deploy/container/README.md`。
|
||||
|
||||
AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。
|
||||
|
||||
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## AGC 素材直传的 OSS 权限必须同步到 Native shell 契约检查
|
||||
|
||||
- **现象**:Native shell CI 在 `check:native-shells:contract` 的 HTTP scope 检查失败,尚未准备 Rust 缓存;后续导出步骤正常退出但实际跳过,导致 master 缓存产物缺组、自动镜像刷新等待。
|
||||
- **原因**:`capabilities/main.json` 已为封面与截图直传加入 `https://*.aliyuncs.com/*`,`scripts/check-native-shells.mjs` 的精确白名单仍只有五项,且误以为更新下载迁至原生 updater 后就不再需要 OSS 权限。`assetDirectUpload.ts` 仍通过 Tauri HTTP 插件执行素材直传。
|
||||
- **处理**:契约期望同步包含现有 OSS HTTPS 项,保留完整白名单精确比较;不得删除业务所需权限或改成任意 URL 放行。更新下载与素材直传是不同调用链,变更能力配置时同步检查调用方和契约。
|
||||
- **验证**:运行 `npm run check:native-shells:contract` 与 `apps/ai-game-creator-shell/tests/assetDirectUpload.test.ts`;缓存完整性以实际 artifact 和导出日志为准,不能仅看上传 step 是否成功。
|
||||
|
||||
## release 安装包文件名中的编码空格不能按控制字符拒绝
|
||||
|
||||
- **现象**:release 环境点击“下载客户端”只显示无法获取最新版本,`GET /api/client-downloads` 返回 `502 UPSTREAM_ERROR`;dev 环境正常。
|
||||
@@ -50,6 +57,14 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只
|
||||
|
||||
`Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET=release` 只控制 Stdb / API / Web 全量发布,不会自动成为 AGC 的 `AGC_UPDATE_CHANNEL`。2026-09-21 的手工发布 #10 就因此让 Windows #107 与 macOS #16 使用默认 `dev`,把 `0.1.95` 上传到 `agc/dev-win`、`agc/dev-mac`,而 `agc/release-win/latest.json`、`agc/release-mac/latest.json` 保持 404。现行口径:手工入口按 `release -> release`、`development -> dev` 同时给 Windows 与 macOS AGC Build 传 `AGC_UPDATE_CHANNEL`;补发已烧号的同一版本时用相同 `AGC_RELEASE_VERSION` 直接重跑两条 AGC Job,不重新发号。OSS 发布对象是 `agc/<channel>-win|mac/`,不存在 `agc/release/` 这一层。
|
||||
|
||||
## Jenkins 渠道参数会进入 Node 测试进程,默认值测试必须隔离环境
|
||||
|
||||
- **现象**:release 渠道的 macOS Job 在 Tauri 构建前的 `build-release.test.mjs` 失败,唯一差异是 `no-bundle smoke skips version writes and manifest generation` 期望 `dev`、实际得到 `release`;构建尚未进入 Rust/Tauri 编译。
|
||||
- **原因**:Jenkins Job 参数 `AGC_UPDATE_CHANNEL=release` 会成为子进程环境变量,而该测试直接依赖未设置的默认渠道,却只显式传了 `--target` 和 `--no-bundle`。
|
||||
- **处理(现行口径)**:断言默认渠道、默认目标等环境派生值的测试,必须用现有 `withEnv` 显式清除相关变量;构建入口继续按 `AGC_UPDATE_CHANNEL` 选择渠道,不为迁就测试改生产逻辑。
|
||||
- **验证**:用 `AGC_UPDATE_CHANNEL=release node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` 复现并验证修复,未设置变量时也必须通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`。
|
||||
|
||||
## 同一条链路两处上限不一致:平台合法产出被客户端整条丢弃
|
||||
|
||||
- 现象:客户端报「生成素材失败:platform-generation-result-unknown: 异步生成完成结果无法绑定到 operationId:External Editor 旧同步结果的图集切片超过 64 个」,而平台侧这次生成**其实已经成功并切完图**(任务账本耗时正常、`assetId` 为空、没有任何素材落盘,付费产物被丢)。
|
||||
|
||||
@@ -137,8 +137,8 @@
|
||||
- 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。
|
||||
- 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。
|
||||
- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。
|
||||
- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。
|
||||
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
|
||||
- 定时调度分别判断服务端与客户端 scope:dev 小时调度在提交含 AGC 相关路径时发布对应渠道,纯文档或流水线自身的提交仍只跑 Full Build;release 每日调度在服务端相关路径变化时发布正式 Full Build,在 AGC 相关路径变化时发布 release 客户端,并在同一调度内等待、汇总各 lane 结果,失败 lane 下一轮补发。判定失败或勾选强制触发时按"需要发布"处理。
|
||||
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题`,标题只取 commit message 第一行并忽略后续说明行;最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
|
||||
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
|
||||
- 上传:安装包与 `.sig` 上传到 `agc/<channel>-win|mac/<version>/`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
|
||||
- 首装发布:发布脚本生成 `downloads`,Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。
|
||||
|
||||
@@ -39,7 +39,7 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与
|
||||
- 发号模块:`apps/ai-game-creator-shell/scripts/agc-global-version.mjs`(读总号 / 播种 / 发号 / 写后回读 / 渠道高水位断言 / dry-run 预览)。
|
||||
- 发号入口:`apps/ai-game-creator-shell/scripts/issue-global-version.mjs`(CI 与本地共用,输出固定为 `AGC_GLOBAL_VERSION=<version>`)。
|
||||
- 构建侧:`build-release.mjs` 的 `prepareReleaseVersion()` 优先采用传入总号,未传入时现场发号;`resolveRemoteHighWaterVersion()` 降级为断言来源,只用于「请求号低于本渠道清单版本即失败关闭」。
|
||||
- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。
|
||||
- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build;release 调度的服务端 Full Build 不使用 AGC 总号,只使用固定 `COMMIT_HASH` 与 Full Build 自身版本。
|
||||
- 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:小时 dev 调度 `Genarrative-Scheduled-Revision-Trigger`、每日 release 调度 `Genarrative-Scheduled-Release-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。
|
||||
|
||||
## 不改的东西
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// AGC release 每日调度管线:每天 04:00 检查源码分支,
|
||||
// 只有上一轮 release 调度之后出现客户端相关路径变更时,才用同一个固定 commit
|
||||
// 触发 AGC Windows/macOS 两个 release 分区构建。
|
||||
// release 每日调度管线:每天 04:00 检查源码分支,
|
||||
// 按独立 scope 触发 release Full Build 与 AGC Windows/macOS,
|
||||
// 等待并汇总结果;只有对应 lane 成功后才推进该 lane 的成功 revision。
|
||||
pipeline {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
@@ -19,17 +19,25 @@ pipeline {
|
||||
environment {
|
||||
GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git'
|
||||
GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh'
|
||||
FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'
|
||||
AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'
|
||||
AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'
|
||||
AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'
|
||||
AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'
|
||||
REVISION_STATE_FILE = '.jenkins-last-release-revision'
|
||||
FULL_REVISION_STATE_FILE = '.jenkins-last-release-full-revision'
|
||||
AGC_REVISION_STATE_FILE = '.jenkins-last-release-agc-revision'
|
||||
AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache'
|
||||
}
|
||||
|
||||
parameters {
|
||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '被检查的源码分支;默认 master')
|
||||
booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 AGC release 渠道')
|
||||
booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 release 服务端与 AGC 双平台')
|
||||
choice(name: 'DATABASE_BACKUP_MODE', choices: ['async', 'sync', 'skip'], description: 'release 发布前 SpacetimeDB 备份策略;常规使用 async,高风险迁移使用 sync,skip 仅限明确无需备份')
|
||||
choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['normal', 'pause-after-stdb'], description: 'normal 为自动完成 Stdb → API;pause-after-stdb 在发布 Stdb 后等待审批人放行 API')
|
||||
string(name: 'STDB_API_ROLLOUT_APPROVERS', defaultValue: '', description: 'pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔')
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', defaultValue: true, description: 'release API 是否构建、归档并部署 Pingora 影子网关')
|
||||
booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', defaultValue: true, description: '完整发布成功后是否退出维护模式;首次验收可暂时关闭')
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -50,8 +58,15 @@ pipeline {
|
||||
}
|
||||
script {
|
||||
env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim()
|
||||
env.LAST_TRIGGERED_REVISION = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : ''
|
||||
env.REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_TRIGGERED_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
|
||||
def legacyRevision = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : ''
|
||||
env.LAST_FULL_REVISION = fileExists(env.FULL_REVISION_STATE_FILE) ? readFile(env.FULL_REVISION_STATE_FILE).trim() : legacyRevision
|
||||
env.LAST_AGC_REVISION = fileExists(env.AGC_REVISION_STATE_FILE) ? readFile(env.AGC_REVISION_STATE_FILE).trim() : legacyRevision
|
||||
env.FULL_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_FULL_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
|
||||
env.AGC_REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_AGC_REVISION != env.REMOTE_REVISION) ? 'true' : 'false'
|
||||
env.REVISION_CHANGED = (env.FULL_REVISION_CHANGED == 'true' || env.AGC_REVISION_CHANGED == 'true') ? 'true' : 'false'
|
||||
if (params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' && !params.STDB_API_ROLLOUT_APPROVERS?.trim()) {
|
||||
error('pause-after-stdb 必须填写 STDB_API_ROLLOUT_APPROVERS。')
|
||||
}
|
||||
if (env.REVISION_CHANGED != 'true') {
|
||||
currentBuild.result = 'NOT_BUILT'
|
||||
currentBuild.description = "release 版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}"
|
||||
@@ -61,24 +76,21 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Resolve AGC Release Scope') {
|
||||
stage('Resolve Release Scope') {
|
||||
when {
|
||||
expression { return env.REVISION_CHANGED == 'true' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||||
script {
|
||||
// 判定失败一律按「需要发布」处理,避免路径过滤阻塞客户端发布。
|
||||
def output = 'agc=changed'
|
||||
// 判定失败一律按「需要发布」处理,避免路径过滤阻塞正式发布。
|
||||
def output = 'agc=changed\nfull=changed'
|
||||
try {
|
||||
output = sh(script: '''#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||||
previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||||
if [[ -z "${previous}" ]]; then
|
||||
printf 'agc=changed\n'
|
||||
exit 0
|
||||
fi
|
||||
previous_agc="$(cat "${AGC_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||||
previous_full="$(cat "${FULL_REVISION_STATE_FILE}" 2>/dev/null || cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||||
mkdir -p "${AGC_SCOPE_CACHE_DIR}"
|
||||
if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet
|
||||
@@ -86,48 +98,121 @@ pipeline {
|
||||
fi
|
||||
refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
||||
if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\n'; exit 0; }
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; }
|
||||
fi
|
||||
if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then
|
||||
echo "浅取窗口内没有 ${previous},按需要发布处理" >&2
|
||||
printf 'agc=changed\n'
|
||||
exit 0
|
||||
fi
|
||||
changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)"
|
||||
changed_paths_for() {
|
||||
local previous="$1"
|
||||
if [[ -z "${previous}" ]] || ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then
|
||||
printf '__changed__\n'
|
||||
return
|
||||
fi
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || printf '__changed__\n'
|
||||
}
|
||||
agc_paths="$(changed_paths_for "${previous_agc}")"
|
||||
agc_scope=unchanged
|
||||
while IFS= read -r changed_path; do
|
||||
[[ -z "${changed_path}" ]] && continue
|
||||
case "${changed_path}" in
|
||||
apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json)
|
||||
agc_scope=changed
|
||||
;;
|
||||
esac
|
||||
done <<< "${changed_paths}"
|
||||
printf 'agc=%s\n' "${agc_scope}"
|
||||
if [[ "${agc_paths}" == '__changed__' ]]; then
|
||||
agc_scope=changed
|
||||
else
|
||||
while IFS= read -r changed_path; do
|
||||
[[ -z "${changed_path}" ]] && continue
|
||||
case "${changed_path}" in
|
||||
apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json)
|
||||
agc_scope=changed
|
||||
;;
|
||||
esac
|
||||
done <<< "${agc_paths}"
|
||||
fi
|
||||
full_paths="$(changed_paths_for "${previous_full}")"
|
||||
full_scope=unchanged
|
||||
if [[ "${full_paths}" == '__changed__' ]]; then
|
||||
full_scope=changed
|
||||
else
|
||||
while IFS= read -r changed_path; do
|
||||
[[ -z "${changed_path}" ]] && continue
|
||||
case "${changed_path}" in
|
||||
docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md)
|
||||
;;
|
||||
*)
|
||||
full_scope=changed
|
||||
;;
|
||||
esac
|
||||
done <<< "${full_paths}"
|
||||
fi
|
||||
printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}"
|
||||
''', returnStdout: true).trim()
|
||||
} catch (error) {
|
||||
echo "release 发布范围判定失败,按需要发布处理:${error}"
|
||||
output = 'agc=changed'
|
||||
output = 'agc=changed\nfull=changed'
|
||||
}
|
||||
def values = output.split('\n').collect { it.trim() }.findAll { it }
|
||||
def entry = values.find { it.startsWith('agc=') }
|
||||
def value = entry == null ? '' : entry.split('=')[1]
|
||||
env.AGC_RELEASE_SCOPE = (value == 'unchanged') ? 'unchanged' : 'changed'
|
||||
echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})"
|
||||
def readScope = { String key ->
|
||||
def entry = values.find { it.startsWith(key + '=') }
|
||||
def value = entry == null ? '' : entry.split('=')[1]
|
||||
return (value == 'unchanged') ? 'unchanged' : 'changed'
|
||||
}
|
||||
env.AGC_RELEASE_SCOPE = readScope('agc')
|
||||
env.FULL_BUILD_SCOPE = readScope('full')
|
||||
echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(AGC=${env.LAST_AGC_REVISION ?: '无'},Full=${env.LAST_FULL_REVISION ?: '无'})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Trigger AGC Release Builds') {
|
||||
stage('Trigger Release Downstreams') {
|
||||
when {
|
||||
expression { return env.REVISION_CHANGED == 'true' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
def pinnedRevision = env.REMOTE_REVISION
|
||||
def agcTriggered = false
|
||||
if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') {
|
||||
def fullTriggered = params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged'
|
||||
def agcTriggered = params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged'
|
||||
def fullParameters = [
|
||||
string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH),
|
||||
string(name: 'COMMIT_HASH', value: pinnedRevision),
|
||||
string(name: 'DEPLOY_TARGET', value: 'release'),
|
||||
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: true),
|
||||
string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE),
|
||||
string(name: 'STDB_API_ROLLOUT_MODE', value: params.STDB_API_ROLLOUT_MODE),
|
||||
string(name: 'STDB_API_ROLLOUT_APPROVERS', value: params.STDB_API_ROLLOUT_APPROVERS?.trim() ?: ''),
|
||||
booleanParam(name: 'INCLUDE_PINGORA_GATEWAY', value: params.INCLUDE_PINGORA_GATEWAY),
|
||||
booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', value: params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION),
|
||||
]
|
||||
if (!fullTriggered) {
|
||||
echo "上一轮 release 调度后没有服务端相关变更,跳过 ${env.FULL_BUILD_JOB_NAME}"
|
||||
}
|
||||
if (!agcTriggered) {
|
||||
echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME}"
|
||||
}
|
||||
def outcome = [:]
|
||||
def runDownstream = { String jobName, List jobParameters, int timeoutMinutes ->
|
||||
try {
|
||||
timeout(time: timeoutMinutes, unit: 'MINUTES') {
|
||||
def run = build job: jobName,
|
||||
wait: true,
|
||||
propagate: false,
|
||||
parameters: jobParameters
|
||||
outcome[jobName] = "#${run.number}:${run.result}"
|
||||
if (run.result != 'SUCCESS') {
|
||||
error("${jobName} 结果=${run.result}")
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!outcome[jobName]) {
|
||||
outcome[jobName] = "异常:${err.message}"
|
||||
}
|
||||
echo "${jobName} 等待失败: ${err.message}"
|
||||
throw err
|
||||
}
|
||||
}
|
||||
def branches = [:]
|
||||
def fullTimeout = params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb' ? 300 : 150
|
||||
if (fullTriggered) {
|
||||
branches[env.FULL_BUILD_JOB_NAME] = {
|
||||
runDownstream(env.FULL_BUILD_JOB_NAME, fullParameters, fullTimeout)
|
||||
}
|
||||
}
|
||||
if (agcTriggered) {
|
||||
// Windows 与 macOS 共用一个总号;发号 Job 是唯一发号源。
|
||||
def versionRun = build job: env.AGC_GLOBAL_VERSION_JOB_NAME,
|
||||
wait: true,
|
||||
@@ -152,22 +237,45 @@ pipeline {
|
||||
string(name: 'AGC_UPDATE_CHANNEL', value: 'release'),
|
||||
string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION),
|
||||
]
|
||||
build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters
|
||||
// macOS 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位。
|
||||
def agcMacosParameters = agcParameters + [
|
||||
booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true),
|
||||
]
|
||||
build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters
|
||||
agcTriggered = true
|
||||
} else {
|
||||
echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
|
||||
branches[env.AGC_WINDOWS_BUILD_JOB_NAME] = {
|
||||
runDownstream(env.AGC_WINDOWS_BUILD_JOB_NAME, agcParameters, 120)
|
||||
}
|
||||
branches[env.AGC_MACOS_BUILD_JOB_NAME] = {
|
||||
runDownstream(env.AGC_MACOS_BUILD_JOB_NAME, agcMacosParameters, 120)
|
||||
}
|
||||
}
|
||||
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
|
||||
if (!branches.isEmpty()) {
|
||||
try {
|
||||
parallel branches
|
||||
} catch (err) {
|
||||
echo "release 并行下游存在失败分支: ${err.message}"
|
||||
}
|
||||
}
|
||||
def fullResult = outcome[env.FULL_BUILD_JOB_NAME]
|
||||
def windowsResult = outcome[env.AGC_WINDOWS_BUILD_JOB_NAME]
|
||||
def macosResult = outcome[env.AGC_MACOS_BUILD_JOB_NAME]
|
||||
def fullSucceeded = !fullTriggered || (fullResult != null && fullResult.endsWith(':SUCCESS'))
|
||||
def agcSucceeded = !agcTriggered || (windowsResult != null && windowsResult.endsWith(':SUCCESS') && macosResult != null && macosResult.endsWith(':SUCCESS'))
|
||||
if (fullSucceeded) {
|
||||
writeFile file: env.FULL_REVISION_STATE_FILE, text: pinnedRevision
|
||||
}
|
||||
if (agcSucceeded) {
|
||||
writeFile file: env.AGC_REVISION_STATE_FILE, text: pinnedRevision
|
||||
}
|
||||
if (fullSucceeded && agcSucceeded) {
|
||||
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
|
||||
}
|
||||
def summary = outcome.collect { jobName, result -> "${jobName}=${result}" }.join(' ')
|
||||
def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
|
||||
currentBuild.description = agcTriggered
|
||||
? "已触发 AGC release:${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME} ${target}"
|
||||
: "本轮没有 AGC 客户端相关变更,未触发 release 发布:${target}"
|
||||
currentBuild.description = "release 调度 ${target};FullBuild=${fullTriggered ? 'triggered' : 'unchanged'};AGC=${agcTriggered ? 'triggered' : 'unchanged'}${summary ? ';' + summary : ''}"
|
||||
echo currentBuild.description
|
||||
if (!fullSucceeded || !agcSucceeded) {
|
||||
error("release 下游未全部成功: ${summary}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +283,7 @@ pipeline {
|
||||
|
||||
post {
|
||||
always {
|
||||
echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''}"
|
||||
echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''} fullScope=${env.FULL_BUILD_SCOPE ?: ''}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.1' encoding='UTF-8'?>
|
||||
<flow-definition plugin="workflow-job">
|
||||
<actions/>
|
||||
<description>每天 04:00 检查源码分支版本(cron 定义在 Jenkinsfile 内):revision 变化且出现客户端相关路径变更时,先经 Genarrative-Agc-Global-Version-Issue 发号,再用同一个固定 commit 与总版本号触发 AGC Windows、macOS 两个 release 分区构建;macOS 额外带 SKIP_IF_SUPERSEDED。下游 Job 一律不自带触发器。</description>
|
||||
<description>每天 04:00 检查源码分支版本(cron 定义在 Jenkinsfile 内):revision 变化且出现服务端相关路径时触发 release Full Build,出现客户端相关路径时经 Genarrative-Agc-Global-Version-Issue 发号并触发 AGC Windows、macOS;调度器等待并汇总三类下游结果,按成功 lane 推进 revision,失败 lane 下一轮补发。下游 Job 一律不自带触发器。</description>
|
||||
<keepDependencies>false</keepDependencies>
|
||||
<properties>
|
||||
<hudson.model.ParametersDefinitionProperty>
|
||||
@@ -14,9 +14,46 @@
|
||||
</hudson.model.StringParameterDefinition>
|
||||
<hudson.model.BooleanParameterDefinition>
|
||||
<name>FORCE_TRIGGER</name>
|
||||
<description>勾选后忽略版本与路径比较,强制发布 AGC release 渠道</description>
|
||||
<description>勾选后忽略版本与路径比较,强制发布 release 服务端与 AGC 双平台</description>
|
||||
<defaultValue>false</defaultValue>
|
||||
</hudson.model.BooleanParameterDefinition>
|
||||
<hudson.model.ChoiceParameterDefinition>
|
||||
<name>DATABASE_BACKUP_MODE</name>
|
||||
<description>release 发布前 SpacetimeDB 备份策略;常规使用 async,高风险迁移使用 sync,skip 仅限明确无需备份</description>
|
||||
<choices class="java.util.Arrays$ArrayList">
|
||||
<a class="string-array">
|
||||
<string>async</string>
|
||||
<string>sync</string>
|
||||
<string>skip</string>
|
||||
</a>
|
||||
</choices>
|
||||
</hudson.model.ChoiceParameterDefinition>
|
||||
<hudson.model.ChoiceParameterDefinition>
|
||||
<name>STDB_API_ROLLOUT_MODE</name>
|
||||
<description>normal 为自动完成 Stdb → API;pause-after-stdb 在发布 Stdb 后等待审批人放行 API</description>
|
||||
<choices class="java.util.Arrays$ArrayList">
|
||||
<a class="string-array">
|
||||
<string>normal</string>
|
||||
<string>pause-after-stdb</string>
|
||||
</a>
|
||||
</choices>
|
||||
</hudson.model.ChoiceParameterDefinition>
|
||||
<hudson.model.StringParameterDefinition>
|
||||
<name>STDB_API_ROLLOUT_APPROVERS</name>
|
||||
<description>pause-after-stdb 必填:允许放行 API 的 Jenkins 用户/组,多个值用逗号分隔</description>
|
||||
<defaultValue></defaultValue>
|
||||
<trim>true</trim>
|
||||
</hudson.model.StringParameterDefinition>
|
||||
<hudson.model.BooleanParameterDefinition>
|
||||
<name>INCLUDE_PINGORA_GATEWAY</name>
|
||||
<description>release API 是否构建、归档并部署 Pingora 影子网关</description>
|
||||
<defaultValue>true</defaultValue>
|
||||
</hudson.model.BooleanParameterDefinition>
|
||||
<hudson.model.BooleanParameterDefinition>
|
||||
<name>EXIT_MAINTENANCE_MODE_AFTER_COMPLETION</name>
|
||||
<description>完整发布成功后是否退出维护模式;首次验收可暂时关闭</description>
|
||||
<defaultValue>true</defaultValue>
|
||||
</hudson.model.BooleanParameterDefinition>
|
||||
</parameterDefinitions>
|
||||
</hudson.model.ParametersDefinitionProperty>
|
||||
</properties>
|
||||
|
||||
@@ -2602,12 +2602,13 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
{ url: 'https://*/api/*' },
|
||||
{ url: 'http://localhost:*/*' },
|
||||
{ url: 'http://127.0.0.1:*/*' },
|
||||
{ url: 'https://*.aliyuncs.com/*' },
|
||||
])
|
||||
) {
|
||||
throw new Error(
|
||||
// 更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成,
|
||||
// 不再需要为 webview 的 http 插件放行 OSS 域名。
|
||||
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, and loopback API boundary',
|
||||
// 更新清单与安装包下载由 tauri-plugin-updater 在原生侧完成;
|
||||
// 封面与截图仍通过 webview 的 http 插件向凭证指定的 OSS 地址直传。
|
||||
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, loopback API, and OSS media upload boundary',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7761,7 +7761,7 @@ for (const [file, content] of [
|
||||
if (/\btriggers\s*\{/u.test(content) || content.includes('cron(')) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由 Genarrative-Scheduled-Revision-Trigger 统一负责。`,
|
||||
`[check:production-ops] ${file} 不得自带定时触发器;版本检查与触发必须由两条定时调度管线统一负责。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7908,6 +7908,10 @@ for (const [snippet, reason] of [
|
||||
['disableConcurrentBuilds()', '必须禁止 release 调度器并发触发'],
|
||||
['skipDefaultCheckout(true)', 'release 调度器不得依赖本地 SCM 工作区'],
|
||||
['git ls-remote', 'release 调度器必须直接从远端解析分支版本'],
|
||||
[
|
||||
"FULL_BUILD_JOB_NAME = 'Genarrative-Full-Build-And-Deploy'",
|
||||
'必须声明 release Full Build 目标 Job',
|
||||
],
|
||||
[
|
||||
"AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'",
|
||||
'必须声明 AGC Windows release 构建目标 Job',
|
||||
@@ -7916,6 +7920,19 @@ for (const [snippet, reason] of [
|
||||
"AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'",
|
||||
'必须声明 AGC macOS release 构建目标 Job',
|
||||
],
|
||||
[
|
||||
"FULL_REVISION_STATE_FILE = '.jenkins-last-release-full-revision'",
|
||||
'必须独立记录 Full Build 成功 revision',
|
||||
],
|
||||
[
|
||||
"AGC_REVISION_STATE_FILE = '.jenkins-last-release-agc-revision'",
|
||||
'必须独立记录 AGC 双平台成功 revision',
|
||||
],
|
||||
['full=changed', 'release 调度器必须计算服务端 Full Build scope'],
|
||||
[
|
||||
"env.FULL_BUILD_SCOPE = readScope('full')",
|
||||
'release 调度器必须解析 Full Build scope',
|
||||
],
|
||||
[
|
||||
"string(name: 'AGC_CHANNEL', value: 'release-unified')",
|
||||
'release 调度器必须先通过发号 Job 获取统一总版本号',
|
||||
@@ -7925,27 +7942,64 @@ for (const [snippet, reason] of [
|
||||
'release 调度器必须把 Windows 与 macOS 都发到 release 分区',
|
||||
],
|
||||
[
|
||||
'build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters',
|
||||
"string(name: 'DEPLOY_TARGET', value: 'release')",
|
||||
'release Full Build 必须固定部署到 release',
|
||||
],
|
||||
[
|
||||
"booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', value: true)",
|
||||
'release Full Build 必须确认使用独立 release 部署 agent',
|
||||
],
|
||||
[
|
||||
"string(name: 'DATABASE_BACKUP_MODE', value: params.DATABASE_BACKUP_MODE)",
|
||||
'release Full Build 必须显式透传数据库备份策略',
|
||||
],
|
||||
[
|
||||
"string(name: 'STDB_API_ROLLOUT_MODE', value: params.STDB_API_ROLLOUT_MODE)",
|
||||
'release Full Build 必须显式透传 Stdb/API rollout 策略',
|
||||
],
|
||||
[
|
||||
'def runDownstream = { String jobName, List jobParameters, int timeoutMinutes ->',
|
||||
'release 调度器必须统一等待并收集三个下游结果',
|
||||
],
|
||||
[
|
||||
'build job: jobName,\n wait: true,\n propagate: false,',
|
||||
'release 下游必须等待完成并保留每个 Job 的结果',
|
||||
],
|
||||
[
|
||||
'branches[env.FULL_BUILD_JOB_NAME] = {',
|
||||
'release 调度器必须触发 Full Build',
|
||||
],
|
||||
[
|
||||
'branches[env.AGC_WINDOWS_BUILD_JOB_NAME] = {',
|
||||
'release 调度器必须触发 AGC Windows release 构建',
|
||||
],
|
||||
[
|
||||
'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters',
|
||||
'branches[env.AGC_MACOS_BUILD_JOB_NAME] = {',
|
||||
'release 调度器必须触发 AGC macOS release 构建',
|
||||
],
|
||||
[
|
||||
"booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)",
|
||||
'release 调度器必须让 macOS 节点恢复后跳过过期排队构建',
|
||||
],
|
||||
[
|
||||
'if (fullSucceeded) {',
|
||||
'只有 Full Build 成功后才能推进 Full Build revision',
|
||||
],
|
||||
['if (agcSucceeded) {', '只有 AGC 双平台成功后才能推进 AGC revision'],
|
||||
[
|
||||
'error("release 下游未全部成功: ${summary}")',
|
||||
'任一 release 下游失败时调度器必须失败',
|
||||
],
|
||||
]) {
|
||||
if (!scheduledReleaseTriggerContent.includes(snippet)) {
|
||||
failed = true;
|
||||
console.error(`[check:production-ops] release 调度管线${reason}。`);
|
||||
}
|
||||
}
|
||||
if (scheduledReleaseTriggerContent.includes('FULL_BUILD_JOB_NAME')) {
|
||||
if (scheduledReleaseTriggerContent.includes('wait: false')) {
|
||||
failed = true;
|
||||
console.error(
|
||||
'[check:production-ops] release 调度管线只负责 AGC release 客户端构建,不得触发 Full Build。',
|
||||
'[check:production-ops] release 调度管线不得 fire-and-forget,必须等待并汇总 Full Build 与 AGC 结果。',
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user