From c17904ee1cf56fb79e1e13522a49ee77432760f7 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Sun, 13 Sep 2026 22:09:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(repo):=E7=BB=9F=E4=B8=80=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E4=B8=8E=E5=8F=AA=E8=AF=BB=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/bat.yml | 23 ++ CURRENT_STATUS.md | 6 +- Makefile | 14 +- README.md | 12 +- TODO.md | 25 +- api/openapi/bat-api.yaml | 14 +- .../architecture/official-resource-backend.md | 7 +- docs/architecture/resource-release-layout.md | 9 +- docs/guides/baseline.md | 25 +- docs/guides/development.md | 26 +- docs/reference/rpc-backend-api.md | 5 +- docs/reports/CURRENT_GAPS.md | 4 + docs/reports/GO_STATUS.md | 13 +- infrastructure/src/release_ops.rs | 28 ++ internal/api/api_test.go | 314 +++++++++++++++++- internal/api/cdn.go | 30 +- internal/api/launcher.go | 1 + internal/api/openapi.go | 14 +- internal/api/release_index.go | 91 +++-- internal/api/responses.go | 28 +- internal/api/rpc_release.go | 180 ++++++++-- internal/api/server.go | 55 ++- internal/backendrpc/client_test.go | 41 ++- scripts/check-doc-status.sh | 13 + scripts/ci-check.sh | 75 +++++ 25 files changed, 869 insertions(+), 184 deletions(-) create mode 100644 scripts/ci-check.sh diff --git a/.gitea/workflows/bat.yml b/.gitea/workflows/bat.yml index 29bab96..bd3cc7a 100644 --- a/.gitea/workflows/bat.yml +++ b/.gitea/workflows/bat.yml @@ -223,6 +223,16 @@ jobs: source /var/lib/act_runner/env.sh make test-go-api + - name: Check Go formatting + shell: bash + run: | + set -euo pipefail + + source /var/lib/act_runner/env.sh + files="$(git ls-files '*.go')" + test -n "${files}" + test -z "$(gofmt -l ${files})" + - name: Run Go API vet shell: bash run: | @@ -239,6 +249,19 @@ jobs: source /var/lib/act_runner/env.sh go build -o /tmp/bat-api ./cmd/bat-api + - name: Optional Go lint + shell: bash + run: | + set -euo pipefail + + source /var/lib/act_runner/env.sh + if command -v golangci-lint >/dev/null 2>&1; then + export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" + golangci-lint run ./... + else + printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n' + fi + - name: Run documentation status gate shell: bash run: | diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 5ba99d8..c234e27 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -262,7 +262,9 @@ marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完 | 空目录 `api/` `pkg/` 等 | 占位 | 无实现 | | Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 | -默认 Go/docs 门禁:`make test-go-api`、`make build-go-api`、`make check-docs`(无 FFI)。 +默认 Go/docs 只读门禁:`make ci-check`(Rust fmt/check/build/clippy/test、Go API +format/test/vet/build、docs/OpenAPI/RPC contract;无 FFI)。`make format` / `make fmt` +才会修改源码;`golangci-lint` 缺失时作为 optional gate 明确输出 skipped。 --- @@ -306,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root` 和 Rust `release.status` 的 whole-release `official_distribution_ready`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 生产要求: diff --git a/Makefile b/Makefile index 55103b7..21e66cb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all +.PHONY: help build build-ffi test clean check check-docs format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all # 默认目标 .DEFAULT_GOAL := help @@ -102,6 +102,8 @@ check-docs: ## 检查权威状态文档与占位目录声明 fmt: fmt-rust fmt-go ## 格式化所有代码 +format: fmt ## 格式化所有代码(会修改工作树) + fmt-rust: ## 格式化 Rust 代码 @echo "$(BLUE)Formatting Rust code...$(NC)" cargo fmt --all @@ -118,9 +120,9 @@ lint-rust: ## Rust Clippy 检查 lint-go: ## Go Linter 检查 @echo "$(BLUE)Running golangci-lint...$(NC)" - @command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; } + @command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)OPTIONAL gate skipped: golangci-lint is not installed$(NC)"; exit 0; } @if [ -n "$$(go list ./... 2>/dev/null)" ]; then \ - golangci-lint run ./...; \ + XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...; \ else \ echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \ fi @@ -189,5 +191,7 @@ docs: ## 生成文档 # CI/CD # ============================================================================ -ci: fmt lint test ## 运行 CI 检查(本地模拟) - @echo "$(GREEN)✓ All CI checks passed!$(NC)" +ci-check: ## 运行只读 required CI 门禁(可选 lint 会明确标记) + @bash scripts/ci-check.sh + +ci: ci-check ## 运行只读 CI 检查(兼容旧命令名) diff --git a/README.md b/README.md index b1946f1..757859f 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,12 @@ 运行当前通用验证: ```bash -cargo fmt --all -- --check -cargo check --workspace -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings -make test-go-api -make build-go-api -go vet ./... -make check-docs +make ci-check ``` +`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。 +如果环境中没有 `golangci-lint`,脚本会明确报告 optional gate skipped。 + 查看官方同步命令: ```bash diff --git a/TODO.md b/TODO.md index 0c9d2c8..8bb2c31 100644 --- a/TODO.md +++ b/TODO.md @@ -52,7 +52,7 @@ # T01 — bat-api 发布完整性与分发健康契约 **类型:** P1 -**状态:** Ready +**状态:** Done **优先级:** 最高 ## 问题 @@ -136,10 +136,17 @@ B 同大小内容损坏,Rust health=false ## 关系 -**阻塞:** +**后续关系:** -* T11 正式长期运行验证 -* Production-ready 判定 +* T11 正式长期运行验证使用本任务建立的 health/readiness evidence。 +* Production-ready 判定仍需结合长期运行证据,不由本任务单独宣告。 + +## 完成记录 + +Rust `release.status.official_distribution_ready` 已接入 Go refresh snapshot; +`readyz`、bootstrap、release summary 和普通 current CDN 共用 whole-release +distribution gate。刷新失败会清空旧 index,恢复后重新建立快照;显式 +official/localized/historical 分发仍经 Rust `release.distribution`。 --- @@ -507,11 +514,11 @@ T07 → Production-ready # T08 — CI Gate 与质量门禁整理 **类型:** Engineering Baseline -**状态:** Ready +**状态:** Done ## 问题 -当前 `make ci` 存在: +原 `make ci` 曾存在: * format target 会直接修改文件; * 部分 lint tool 缺失时可能 skip; @@ -540,6 +547,12 @@ CI 检查必须: * optional gate 明确标记 optional; * 与实际 Gitea CI 尽量保持一致。 +## 完成记录 + +`make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的 +`make ci` 只执行 read-only required gates;Go lint 在工具缺失时明确输出 +optional skipped,OpenAPI、RPC contract 和文档一致性由 `make check-docs` 纳入。 + 建议统一覆盖: ```text diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 8b61834..186cf47 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -20,25 +20,25 @@ paths: summary: Release readiness responses: "200": - description: A distributable release is available. + description: A release authorized by Rust release.status and fully represented by the local read snapshot is available. "503": - description: No distributable release is available. + description: The Rust whole-release distribution health fact or the local read snapshot is not distributable. /v1/bootstrap: get: summary: Startup resource bootstrap responses: "200": - description: Resource bootstrap response. + description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving. "503": - description: Release is not ready. + description: The current release is not distributable. /v1/launcher/bootstrap: get: summary: Launcher-shaped resource bootstrap responses: "200": - description: Launcher bootstrap response. + description: Launcher bootstrap response with the current release distribution health. "503": - description: Release is not ready. + description: The current release is not distributable. /api/launcher/game/config: get: summary: Resource-only launcher game config compatibility @@ -71,7 +71,7 @@ paths: summary: Current release summary responses: "200": - description: Release summary. + description: Release summary including Rust-owned whole-release distribution health. /v1/releases: get: summary: Rust-owned official and localized release history diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 3125ac4..033d3b9 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -370,8 +370,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。 - CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 - - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再 - `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 + - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取 + Rust `release.status` 的 whole-release `official_distribution_ready`,最后 + 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 + Go 不重新实现 release verifier;普通 current CDN 只有在该 Rust health fact + 和本地只读 manifest 快照都允许时才分发。 - `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、 `/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed 结果;localized 或历史分发不会绕过 Rust 完整性判断。 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 436ea18..3692c21 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -382,9 +382,12 @@ Addressables 改写后客户端拼接: 1. `daemon.status` 2. `daemon.doctor` -3. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle) -4. `resource.manifest` 分页(url / destination / bytes / blake3) -5. 在 `resource_root` 上 Lstat 校验 Present / size +3. `release.status`,消费 Rust 返回的当前 official `official_distribution_ready` + 及其 `status` / `status_code` / integrity 事实 +4. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle) +5. `resource.manifest` 分页(url / destination / bytes / blake3) +6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照, + 不替代 Rust release verifier **不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。 diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index 6f186de..a201e88 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -23,29 +23,30 @@ ## 2. 当前验证命令 -必须通过: +提交前的只读统一门禁必须通过: ```bash -make test -make check -make lint +make ci-check ``` -等价底层命令: +该命令等价覆盖: ```bash -cargo test --workspace -cargo check --workspace -cargo clippy --workspace --all-targets -- -D warnings -go test ./internal/api/... ./internal/backendrpc/... -go vet ./... +cargo fmt --all -- --check +cargo check --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... +go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... +go build -o /tmp/bat-api ./cmd/bat-api +make check-docs ``` 说明: 1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。 -2. `make check` 当前直接执行 `go vet ./...`,因此会检查所有已存在的 Go 包;新增 Go 产品 package 后,必须同时纳入默认测试门禁。 -3. `golangci-lint` 当前仍是可选补充门禁;Go 的硬性验证是默认 API 测试、全量 `go vet` 和 `bat-api` 构建。 +2. `golangci-lint` 当前仍是 optional gate;命令可用时执行并失败即阻断,不可用时明确输出 skipped 及原因。 +3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。 如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如 diff --git a/docs/guides/development.md b/docs/guides/development.md index 7974d3d..22ac7d9 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -36,7 +36,7 @@ cargo clippy --version go version ``` -该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。 +该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的只读格式检查、检查、构建、clippy 和测试,以及 Go API 格式、测试、vet、构建、可选 lint 和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。 #### Docker ```bash @@ -66,14 +66,11 @@ git checkout -b feature/your-feature-name ### 2. 开发 ```bash -# 实时编译检查 -make check +# 运行只读 required 门禁(不会格式化或修改源码) +make ci-check -# 运行测试 -make test - -# 格式化代码 -make fmt +# 需要格式化时才修改工作树 +make format ``` 开发约束: @@ -143,20 +140,17 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续 ### 合并前通用门禁 ```bash -cargo fmt --all -- --check -cargo test --workspace -cargo clippy --workspace --all-targets -- -D warnings -make test-go-api -make build-go-api -go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... -make check-docs +make ci-check ``` +`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。缺少 +`golangci-lint` 时,Go lint 作为 optional gate 明确输出 skipped,不会伪报全部门禁通过。 + Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: - **同步/运维命令行** = Rust `bat`(近乎全自动) - **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`) -- **默认 Go 门禁** = `make test-go-api`(无 FFI) +- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build(无 FFI) - 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名 - 修改 FFI 时再跑 `make test-go-ffi` diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index e9a192b..12d1f16 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -176,7 +176,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 | 方法 | 状态 | params | data | |---|---|---|---| -| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图。 | +| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图;`official_distribution_ready` 是 bat-api 普通 current release readiness/distribution 使用的 Rust-owned whole-release health fact。 | | `release.list` | 已实现 | `{ "channel": "official" }` 或 `{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 | | `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 | | `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 | @@ -565,7 +565,8 @@ CLI 对应关系: | Go 接口 | 允许调用的 RPC | 用途 | |---|---|---| -| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 | +| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发基础数据 | +| `ReleaseStatusBackend` | `release.status` | 启动发现和周期刷新使用 Rust-owned whole-release distribution health;Go 不重新实现 verifier | | `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 | | `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 | | `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 | diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 86bdb72..5a99410 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -75,6 +75,10 @@ provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。 launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌 dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC 转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。 +普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用 +Rust `release.status.official_distribution_ready` whole-release health fact;Go +只建立 manifest 的读快照,不复制 Rust verifier。`make ci-check` 是只读统一门禁, +缺少 `golangci-lint` 时明确报告 optional skipped。 仍需完成: diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 7e051be..e0330a7 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -61,11 +61,11 @@ | ID | 约定 | |---|---| | F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 | -| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest | +| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 **`release.status`**,再 catalog/manifest | | H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | | I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 | -| J | 索引以 **manifest + 磁盘 Present/size** 为准 | -| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 | +| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity | +| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`release.status.official_distribution_ready` 是普通 current 分发的 whole-release 授权事实,`bat-api` 只读消费,不自行推导 verifier | ### 进程配置 @@ -86,7 +86,7 @@ | O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 | | P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` | | Q | 空目录标明 reserved empty | -| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI) | +| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build(无 FFI),`golangci-lint` 为明确报告的 optional gate | --- @@ -116,10 +116,7 @@ ```bash # 默认(提交前 / CI 建议) -make test-go-api -make build-go-api -go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... -make check-docs +make ci-check # 可选:改 FFI 或试验 CLI 时 make build-ffi diff --git a/infrastructure/src/release_ops.rs b/infrastructure/src/release_ops.rs index eaae779..28db1ad 100644 --- a/infrastructure/src/release_ops.rs +++ b/infrastructure/src/release_ops.rs @@ -1697,6 +1697,34 @@ mod tests { assert!(!localized.unknown); assert_eq!(report.releases.len(), 2); + let other_path = official_version.join("other.bin"); + let other_bytes = fs::read(&other_path).unwrap(); + let mut same_size_corruption = other_bytes.clone(); + same_size_corruption[0] ^= 0xff; + fs::write(&other_path, &same_size_corruption).unwrap(); + let same_size_report = + build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap(); + let same_size_official = same_size_report + .releases + .iter() + .find(|release| release.channel == "official" && release.current) + .unwrap(); + assert_eq!(same_size_official.distribution_integrity_status, "invalid"); + assert!(!same_size_report.official_distribution_ready); + fs::write(&other_path, &other_bytes).unwrap(); + + fs::write(&other_path, [other_bytes.as_slice(), b"!"].concat()).unwrap(); + let size_report = + build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap(); + assert!(!size_report.official_distribution_ready); + fs::write(&other_path, &other_bytes).unwrap(); + + fs::remove_file(&other_path).unwrap(); + let missing_report = + build_release_status(&official_root, &localized_root, Path::new("unzip")).unwrap(); + assert!(!missing_report.official_distribution_ready); + fs::write(&other_path, &other_bytes).unwrap(); + fs::remove_file(official_version.join(crate::OFFICIAL_DISTRIBUTION_PUBLICATION_FILE)) .unwrap(); let missing_publication = select_release_distribution( diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 3778349..eb5ac30 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -32,6 +32,89 @@ func fixtureRoot(t *testing.T) string { return abs } +func copyFixtureRoot(t *testing.T) string { + t.Helper() + source := fixtureRoot(t) + target := filepath.Join(t.TempDir(), "release") + if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + destination := filepath.Join(target, rel) + if info.IsDir() { + return os.MkdirAll(destination, info.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(destination, data, info.Mode().Perm()) + }); err != nil { + t.Fatal(err) + } + return target +} + +func fixtureRPCBackend(t *testing.T, root string) *fakeBackend { + t.Helper() + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries)) + for _, entry := range idx.Entries { + size := entry.Bytes + manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{ + URL: entry.URL, + Destination: entry.RelativePath, + Bytes: &size, + BLAKE3: entry.BLAKE3, + }) + } + catalog, err := json.Marshal(map[string]any{ + "available": true, + "status": "published", + "version": map[string]any{ + "id": "official-fixture", + "resource_root": root, + }, + }) + if err != nil { + t.Fatal(err) + } + return &fakeBackend{ + status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true}, + doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"}, + catalog: catalog, + manifest: &backendrpc.ResourceManifestPage{ + Available: true, + ResourceRoot: root, + ManifestVersion: 1, + TotalEntries: len(manifestEntries), + Entries: manifestEntries, + }, + releaseStatus: &backendrpc.ReleaseStatusReport{ + Status: "ready", + StatusCode: "distribution.ready", + OfficialCurrentReleaseID: "official-fixture", + DefaultDistributionChannel: "official", + OfficialDistributionReady: true, + Releases: []backendrpc.ReleaseSummary{{ + Channel: "official", + ID: "official-fixture", + Current: true, + DistributionIntegrityStatus: "valid", + }}, + }, + } +} + +const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" + func TestLoadIndexFromResourceRoot(t *testing.T) { idx, err := LoadIndexFromResourceRoot(fixtureRoot(t)) if err != nil { @@ -89,6 +172,188 @@ func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) { } } +func TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) { + tests := []struct { + name string + mutate func(t *testing.T, root string) + }{ + { + name: "missing entry", + mutate: func(t *testing.T, root string) { + t.Helper() + if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "size mismatch", + mutate: func(t *testing.T, root string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "same-size corruption", + mutate: func(t *testing.T, root string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := copyFixtureRoot(t) + tt.mutate(t, root) + backend := fixtureRPCBackend(t, root) + backend.releaseStatus = &backendrpc.ReleaseStatusReport{ + Status: "blocked", + StatusCode: "distribution.blocked", + OfficialCurrentReleaseID: "official-fixture", + DefaultDistributionChannel: "official", + OfficialDistributionReady: false, + Releases: []backendrpc.ReleaseSummary{{ + Channel: "official", + ID: "official-fixture", + Current: true, + DistributionIntegrityStatus: "invalid", + Diagnostics: []string{"fixture integrity failure"}, + }}, + } + cfg := DefaultConfig() + cfg.RequireIndexed = false + cfg.RefreshInterval = 0 + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + server := NewServer(cfg, backend, log.New(io.Discard, "", 0)) + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + + summary := server.index().Summary() + if summary.Ready || summary.Distribution.Ready || + summary.Distribution.StatusCode != "distribution.blocked" { + t.Fatalf("summary=%+v", summary) + } + for _, path := range []string{"/readyz", "/v1/bootstrap"} { + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String()) + } + } + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil)) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" { + t.Fatalf("unhealthy CDN headers=%v", recorder.Header()) + } + }) + } +} + +func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) { + root := copyFixtureRoot(t) + backend := fixtureRPCBackend(t, root) + cfg := DefaultConfig() + cfg.RefreshInterval = 0 + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + server := NewServer(cfg, backend, log.New(io.Discard, "", 0)) + + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if !server.index().Summary().Ready { + t.Fatal("initial release is not ready") + } + get := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil)) + return recorder + } + if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" { + t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String()) + } + + // The bytes stay the same size, but Rust's next release.status result + // revokes whole-release distribution authorization. + if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil { + t.Fatal(err) + } + backend.releaseStatus = &backendrpc.ReleaseStatusReport{ + Status: "blocked", + StatusCode: "distribution.blocked", + OfficialCurrentReleaseID: "official-fixture", + DefaultDistributionChannel: "official", + Releases: []backendrpc.ReleaseSummary{{ + Channel: "official", + ID: "official-fixture", + Current: true, + DistributionIntegrityStatus: "invalid", + }}, + } + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable { + t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary()) + } + + backend.releaseStatusErr = errors.New("release.status transport failure") + if err := server.Refresh(context.Background()); err == nil { + t.Fatal("expected refresh failure") + } + if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" { + t.Fatalf("failed refresh retained snapshot=%+v", summary) + } + healthRecorder := httptest.NewRecorder() + server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + var health map[string]any + if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + refresh := health["refresh"].(map[string]any) + if refresh["last_error"] == "" { + t.Fatalf("refresh diagnostics=%v", refresh) + } + + if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil { + t.Fatal(err) + } + backend.releaseStatusErr = nil + backend.releaseStatus = &backendrpc.ReleaseStatusReport{ + Status: "ready", + StatusCode: "distribution.ready", + OfficialCurrentReleaseID: "official-fixture", + DefaultDistributionChannel: "official", + OfficialDistributionReady: true, + Releases: []backendrpc.ReleaseSummary{{ + Channel: "official", + ID: "official-fixture", + Current: true, + DistributionIntegrityStatus: "valid", + }}, + } + if err := server.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready { + t.Fatalf("recovered summary=%+v", summary) + } + if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" { + t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String()) + } +} + func TestSplitCDNPathRejectsEscape(t *testing.T) { if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil { t.Fatal("expected error") @@ -553,18 +818,21 @@ func TestServerInfoRewritesAddressablesOnly(t *testing.T) { } type fakeBackend struct { - statusCalls int - doctorCalls int - status *backendrpc.DaemonStatusReport - doctor *backendrpc.DoctorReport - catalog json.RawMessage - resource *backendrpc.ResourceState - manifest *backendrpc.ResourceManifestPage - daemonLogs *backendrpc.LogsReport - taskList *backendrpc.TaskList - taskStatus *backendrpc.TaskRecord - taskLogs *backendrpc.TaskLogs - taskCancel *backendrpc.TaskCancelResult + statusCalls int + doctorCalls int + releaseStatusCalls int + status *backendrpc.DaemonStatusReport + doctor *backendrpc.DoctorReport + releaseStatus *backendrpc.ReleaseStatusReport + releaseStatusErr error + catalog json.RawMessage + resource *backendrpc.ResourceState + manifest *backendrpc.ResourceManifestPage + daemonLogs *backendrpc.LogsReport + taskList *backendrpc.TaskList + taskStatus *backendrpc.TaskRecord + taskLogs *backendrpc.TaskLogs + taskCancel *backendrpc.TaskCancelResult } func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { @@ -573,11 +841,23 @@ func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatu } func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { f.doctorCalls++ - if f.statusCalls == 0 { - // status must be called first in real DiscoverAndIndex; this is asserted by call order. - } return f.doctor, nil } +func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) { + f.releaseStatusCalls++ + if f.releaseStatusErr != nil { + return nil, f.releaseStatusErr + } + if f.releaseStatus != nil { + return f.releaseStatus, nil + } + return &backendrpc.ReleaseStatusReport{ + Status: "ready", + StatusCode: "distribution.ready", + DefaultDistributionChannel: "official", + OfficialDistributionReady: true, + }, nil +} func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { if f.resource != nil { return f.resource, nil @@ -1243,8 +1523,8 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { if err != nil { t.Fatal(err) } - if fb.statusCalls != 1 || fb.doctorCalls != 1 { - t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls) + if fb.statusCalls != 1 || fb.doctorCalls != 1 || fb.releaseStatusCalls != 1 { + t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls) } if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready { t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary()) diff --git a/internal/api/cdn.go b/internal/api/cdn.go index bb24a63..f7b2a67 100644 --- a/internal/api/cdn.go +++ b/internal/api/cdn.go @@ -50,16 +50,20 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { http.Error(w, "resource root not ready", http.StatusServiceUnavailable) return } - resourceRoot = idx.ResourceRoot - if s.cfg.RequireIndexed { - entry, hasEntry = idx.Lookup(rel) - } - } - if explicitRelease || s.cfg.RequireIndexed { - if !hasEntry || !entry.Present || !entry.SizeMatch { - http.NotFound(w, r) + summary := idx.Summary() + if !summary.Ready { + // The cached health fact covers the whole current release. A + // locally present target is not enough to serve it as a healthy + // immutable artifact. + http.Error(w, "current release is not distributable", http.StatusServiceUnavailable) return } + resourceRoot = idx.ResourceRoot + entry, hasEntry = idx.Lookup(rel) + } + if !hasEntry || !entry.Present || !entry.SizeMatch { + http.NotFound(w, r) + return } abs, err := ResolveUnderRoot(resourceRoot, rel, true) @@ -76,11 +80,9 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { http.Error(w, "size mismatch with release index", http.StatusConflict) return } - if !explicitRelease && s.cfg.RequireIndexed && s.cfg.VerifySize { - if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { - http.Error(w, "size mismatch with release index", http.StatusConflict) - return - } + if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { + http.Error(w, "size mismatch with release index", http.StatusConflict) + return } file, err := os.Open(abs) @@ -88,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - defer file.Close() + defer func() { _ = file.Close() }() w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("ETag", cdnETag(entry, hasEntry, info)) diff --git a/internal/api/launcher.go b/internal/api/launcher.go index 495c98f..b1a00b3 100644 --- a/internal/api/launcher.go +++ b/internal/api/launcher.go @@ -111,6 +111,7 @@ func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResp }, Resource: LauncherResource{ Release: sum.Snapshot, + Distribution: sum.Distribution, ServerInfoURL: s.serverInfoURL(), ClientPatchBaseURL: s.clientPatchBaseURL(), }, diff --git a/internal/api/openapi.go b/internal/api/openapi.go index fb36636..a442c69 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -27,25 +27,25 @@ paths: summary: Release readiness responses: "200": - description: A distributable release is available. + description: A release authorized by Rust release.status and fully represented by the local read snapshot is available. "503": - description: No distributable release is available. + description: The Rust whole-release distribution health fact or the local read snapshot is not distributable. /v1/bootstrap: get: summary: Startup resource bootstrap responses: "200": - description: Resource bootstrap response. + description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving. "503": - description: Release is not ready. + description: The current release is not distributable. /v1/launcher/bootstrap: get: summary: Launcher-shaped resource bootstrap responses: "200": - description: Launcher bootstrap response. + description: Launcher bootstrap response with the current release distribution health. "503": - description: Release is not ready. + description: The current release is not distributable. /api/launcher/game/config: get: summary: Resource-only launcher game config compatibility @@ -78,7 +78,7 @@ paths: summary: Current release summary responses: "200": - description: Release summary. + description: Release summary including Rust-owned whole-release distribution health. /v1/releases: get: summary: Rust-owned official and localized release history diff --git a/internal/api/release_index.go b/internal/api/release_index.go index 0184843..12feded 100644 --- a/internal/api/release_index.go +++ b/internal/api/release_index.go @@ -55,17 +55,33 @@ type GameMainConfigSummary struct { DefaultConnectionGroup string `json:"default_connection_group,omitempty"` } +// DistributionHealth is the release-level authorization used by read paths. +// +// In RPC mode Ready is copied from Rust's release.status +// official_distribution_ready fact. The local manifest checks only establish +// that this process has a usable read snapshot; they do not replace Rust's +// release verifier. +type DistributionHealth struct { + Ready bool `json:"ready"` + Source string `json:"source"` + Status string `json:"status,omitempty"` + StatusCode string `json:"status_code,omitempty"` + IntegrityStatus string `json:"integrity_status,omitempty"` + Diagnostics []string `json:"diagnostics,omitempty"` +} + // ReleaseIndex is the in-memory view of a published resource root. type ReleaseIndex struct { mu sync.RWMutex - ResourceRoot string `json:"resource_root"` - Source string `json:"source"` // "rpc" | "resource_root" | "empty" - RPCAvailable bool `json:"rpc_available"` - DoctorHealthy *bool `json:"doctor_healthy,omitempty"` - Snapshot *SnapshotSummary `json:"snapshot,omitempty"` - ManifestVersion int `json:"manifest_version,omitempty"` - Entries []ResourceEntry `json:"entries"` + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` // "rpc" | "resource_root" | "empty" + RPCAvailable bool `json:"rpc_available"` + DoctorHealthy *bool `json:"doctor_healthy,omitempty"` + Distribution DistributionHealth `json:"distribution"` + Snapshot *SnapshotSummary `json:"snapshot,omitempty"` + ManifestVersion int `json:"manifest_version,omitempty"` + Entries []ResourceEntry `json:"entries"` // byRel maps relative path (host/path...) to entry index. byRel map[string]int // MissingOnDisk lists relative paths present in the index but absent on disk. @@ -74,16 +90,17 @@ type ReleaseIndex struct { // Summary returns a JSON-serializable overview without the full entry list. type ReleaseSummary struct { - ResourceRoot string `json:"resource_root"` - Source string `json:"source"` - RPCAvailable bool `json:"rpc_available"` - DoctorHealthy *bool `json:"doctor_healthy,omitempty"` - Snapshot *SnapshotSummary `json:"snapshot,omitempty"` - ManifestVersion int `json:"manifest_version,omitempty"` - EntryCount int `json:"entry_count"` - PresentCount int `json:"present_count"` - MissingCount int `json:"missing_count"` - Ready bool `json:"ready"` + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` + RPCAvailable bool `json:"rpc_available"` + DoctorHealthy *bool `json:"doctor_healthy,omitempty"` + Distribution DistributionHealth `json:"distribution"` + Snapshot *SnapshotSummary `json:"snapshot,omitempty"` + ManifestVersion int `json:"manifest_version,omitempty"` + EntryCount int `json:"entry_count"` + PresentCount int `json:"present_count"` + MissingCount int `json:"missing_count"` + Ready bool `json:"ready"` } // Summary builds a compact release overview. @@ -96,20 +113,32 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary { present++ } } + distribution := idx.Distribution + distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...) + localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries) + // Hand-built indexes are retained for compatibility with local tests and + // diagnostics. Any index explicitly sourced from RPC must carry the Rust + // health fact; an RPC index without it is never considered distributable. + if distribution.Source == "" { + distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest" + } else { + distribution.Ready = distribution.Ready && localComplete + } return ReleaseSummary{ ResourceRoot: idx.ResourceRoot, Source: idx.Source, RPCAvailable: idx.RPCAvailable, DoctorHealthy: idx.DoctorHealthy, + Distribution: distribution, Snapshot: idx.Snapshot, ManifestVersion: idx.ManifestVersion, EntryCount: len(idx.Entries), PresentCount: present, MissingCount: len(idx.MissingOnDisk), - // A release is distributable only when every manifest entry is present - // and has the expected size. Serving a partial release can leave clients - // with an apparently valid bootstrap and an unrecoverable download set. - Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries), + // A release is distributable only when Rust authorizes it and every + // entry in this process's read snapshot is usable. Serving a partial + // release can leave clients with an unrecoverable download set. + Ready: distribution.Ready, } } @@ -158,6 +187,7 @@ func BuildIndexFromManifestEntries( snapshot *SnapshotSummary, manifestVersion int, entries []manifestEntry, + distribution DistributionHealth, ) (*ReleaseIndex, error) { rootAbs, err := filepath.Abs(resourceRoot) if err != nil { @@ -168,7 +198,8 @@ func BuildIndexFromManifestEntries( Source: source, RPCAvailable: rpcAvailable, DoctorHealthy: doctorHealthy, - Snapshot: snapshot, + Distribution: distribution, + Snapshot: snapshotWithDistributionHealth(snapshot, distribution), ManifestVersion: manifestVersion, byRel: make(map[string]int), } @@ -258,7 +289,21 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) { } } } - return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries) + return BuildIndexFromManifestEntries( + rootAbs, + "resource_root", + false, + nil, + snapshot, + manifest.Version, + entries, + DistributionHealth{ + Ready: true, + Source: "resource_root_override", + Status: "ready", + StatusCode: "resource_root.ready", + }, + ) } type manifestEntry struct { diff --git a/internal/api/responses.go b/internal/api/responses.go index a32869a..311faab 100644 --- a/internal/api/responses.go +++ b/internal/api/responses.go @@ -51,16 +51,17 @@ type BootstrapAPI struct { } type BootstrapResource struct { - Release *SnapshotSummary `json:"release,omitempty"` - ResourceRoot string `json:"resource_root"` - Source string `json:"source"` - ManifestVersion int `json:"manifest_version,omitempty"` - EntryCount int `json:"entry_count"` - PresentCount int `json:"present_count"` - MissingCount int `json:"missing_count"` - ServerInfoURL string `json:"server_info_url"` - ClientPatchBaseURL string `json:"client_patch_base_url"` - AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"` + Release *SnapshotSummary `json:"release,omitempty"` + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` + Distribution DistributionHealth `json:"distribution"` + ManifestVersion int `json:"manifest_version,omitempty"` + EntryCount int `json:"entry_count"` + PresentCount int `json:"present_count"` + MissingCount int `json:"missing_count"` + ServerInfoURL string `json:"server_info_url"` + ClientPatchBaseURL string `json:"client_patch_base_url"` + AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"` } type BootstrapPolicy struct { @@ -113,9 +114,10 @@ type LauncherPolicy struct { } type LauncherResource struct { - Release *SnapshotSummary `json:"release,omitempty"` - ServerInfoURL string `json:"server_info_url"` - ClientPatchBaseURL string `json:"client_patch_base_url"` + Release *SnapshotSummary `json:"release,omitempty"` + Distribution DistributionHealth `json:"distribution"` + ServerInfoURL string `json:"server_info_url"` + ClientPatchBaseURL string `json:"client_patch_base_url"` } type LauncherEndpointSet struct { diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 0c650f2..9ad5811 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -113,10 +113,17 @@ type LocalizedBackend interface { LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) } +// ReleaseStatusBackend exposes the Rust-owned release health fact used during +// discovery. It is kept separate so lightweight test/diagnostic backends do +// not have to implement the administrative release surface. +type ReleaseStatusBackend interface { + ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) +} + // ReleaseBackend exposes Rust-owned dual-release queries, distribution // selection and the explicit cleanup operation. type ReleaseBackend interface { - ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) + ReleaseStatusBackend ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) @@ -303,14 +310,18 @@ type DiscoverResult struct { DoctorHealthy *bool Status *backendrpc.DaemonStatusReport Doctor *backendrpc.DoctorReport + ReleaseStatus *backendrpc.ReleaseStatusReport + Distribution DistributionHealth Snapshot *SnapshotSummary ResourceRoot string Index *ReleaseIndex Warnings []string } -// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds -// a release index from paginated resource.manifest plus on-disk checks. +// DiscoverAndIndex contacts the daemon (status first, then doctor, then +// release.status) and builds a release index from paginated resource.manifest +// plus on-disk checks. Rust's release.status is the only release-level +// integrity authorization used for the production RPC path. // // If resourceRootOverride is non-empty, it wins over RPC-reported roots after // RPC health probes (still preferred for production to call status/doctor). @@ -326,6 +337,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride return nil, err } out.ResourceRoot = idx.ResourceRoot + out.Distribution = idx.Distribution out.Index = idx return out, nil } @@ -340,6 +352,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr) } out.ResourceRoot = idx.ResourceRoot + out.Distribution = idx.Distribution out.Index = idx return out, nil } @@ -359,6 +372,45 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride out.DoctorHealthy = &h } + // An explicit root is a fixture/emergency read-only override. Keep it + // outside the production RPC release-health contract, while still probing + // daemon status and doctor first. + if resourceRootOverride != "" { + idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride) + if loadErr != nil { + return out, fmt.Errorf("resource-root override load failed: %w", loadErr) + } + idx.Source = "resource_root" + idx.RPCAvailable = true + idx.DoctorHealthy = out.DoctorHealthy + out.ResourceRoot = idx.ResourceRoot + out.Snapshot = idx.Snapshot + out.Distribution = idx.Distribution + out.Index = idx + return out, nil + } + + // 3) release.status is the Rust-owned whole-release distribution gate. + releaseStatusBackend, ok := backend.(ReleaseStatusBackend) + if !ok { + out.Warnings = append(out.Warnings, "release.status: backend does not expose Rust release health") + return emptyRPCResult(out, nil, "Rust release health is unavailable"), + fmt.Errorf("rust release health is unavailable") + } + releaseStatus, err := releaseStatusBackend.ReleaseStatus(ctx) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("release.status: %v", err)) + return emptyRPCResult(out, nil, "Rust release health query failed"), + fmt.Errorf("release.status failed: %w", err) + } + if releaseStatus == nil { + out.Warnings = append(out.Warnings, "release.status: empty response") + return emptyRPCResult(out, nil, "Rust release health query returned no response"), + fmt.Errorf("release.status returned an empty response") + } + out.ReleaseStatus = releaseStatus + out.Distribution = rustDistributionHealth(releaseStatus) + // Catalog / resource discovery var snapshot *SnapshotSummary var resourceRoot string @@ -375,6 +427,20 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride resourceRoot = root } } + if snapshot != nil && + snapshot.VersionID != "" && + out.ReleaseStatus.OfficialCurrentReleaseID != "" && + snapshot.VersionID != out.ReleaseStatus.OfficialCurrentReleaseID { + return emptyRPCResult( + out, + snapshot, + fmt.Sprintf( + "release.status current ID %q does not match catalog current ID %q", + out.ReleaseStatus.OfficialCurrentReleaseID, + snapshot.VersionID, + ), + ), nil + } if catalogAvailabilityKnown && !catalogAvailable { return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil } @@ -393,12 +459,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride resourceRoot = resourceRootOverride } if resourceRoot == "" { - out.Snapshot = snapshot + out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution) out.Index = &ReleaseIndex{ Source: "rpc", RPCAvailable: true, DoctorHealthy: out.DoctorHealthy, - Snapshot: snapshot, + Distribution: out.Distribution, + Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution), byRel: map[string]int{}, } out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version") @@ -421,28 +488,14 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend) if err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) - // Fallback: load local manifest file under root. - idx, loadErr := LoadIndexFromResourceRoot(resourceRoot) - if loadErr != nil { - if resourceRootOverride == "" { - return emptyRPCResult( - out, - snapshot, - fmt.Sprintf("published release cannot be indexed: %v", loadErr), - ), nil - } - return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr) - } - idx.Source = "rpc+local_manifest" - idx.RPCAvailable = true - idx.DoctorHealthy = out.DoctorHealthy - if snapshot != nil { - idx.Snapshot = snapshot - } - out.ResourceRoot = idx.ResourceRoot - out.Snapshot = idx.Snapshot - out.Index = idx - return out, nil + // Without the RPC manifest there is no evidence that the local + // snapshot matches the Rust health fact. Do not pair a fresh health + // result with a potentially stale on-disk manifest. + return emptyRPCResult( + out, + snapshot, + fmt.Sprintf("published release cannot be indexed: %v", err), + ), nil } if rootFromManifest != "" { resourceRoot = rootFromManifest @@ -459,6 +512,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride snapshot, manifestVersion, entries, + out.Distribution, ) if err != nil { if resourceRootOverride == "" { @@ -471,18 +525,25 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride return out, err } out.ResourceRoot = idx.ResourceRoot - out.Snapshot = snapshot + out.Snapshot = idx.Snapshot out.Index = idx return out, nil } func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult { + distribution := out.Distribution + if distribution.Source == "" { + distribution = unavailableRustDistributionHealth() + out.Distribution = distribution + } + snapshot = snapshotWithDistributionHealth(snapshot, distribution) out.ResourceRoot = "" out.Snapshot = snapshot out.Index = &ReleaseIndex{ Source: "rpc", RPCAvailable: true, DoctorHealthy: out.DoctorHealthy, + Distribution: distribution, Snapshot: snapshot, byRel: map[string]int{}, } @@ -492,6 +553,69 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri return out } +func rustDistributionHealth(report *backendrpc.ReleaseStatusReport) DistributionHealth { + health := DistributionHealth{ + Source: "rust_release_status", + Status: "blocked", + StatusCode: "distribution.blocked", + IntegrityStatus: "unknown", + } + if report == nil { + return health + } + health.Ready = report.OfficialDistributionReady + health.Status = report.Status + health.StatusCode = report.StatusCode + if health.Status == "" { + if health.Ready { + health.Status = "ready" + } else { + health.Status = "blocked" + } + } + if health.StatusCode == "" { + if health.Ready { + health.StatusCode = "distribution.ready" + } else { + health.StatusCode = "distribution.blocked" + } + } + for _, release := range report.Releases { + if release.Channel == "official" && release.Current { + health.IntegrityStatus = release.DistributionIntegrityStatus + health.Diagnostics = append([]string(nil), release.Diagnostics...) + break + } + } + if health.IntegrityStatus == "valid" && !health.Ready { + health.IntegrityStatus = "invalid" + } + return health +} + +func unavailableRustDistributionHealth() DistributionHealth { + return DistributionHealth{ + Source: "rust_release_status", + Status: "unavailable", + StatusCode: "distribution.health_unavailable", + IntegrityStatus: "unknown", + } +} + +func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary { + if snapshot == nil { + return nil + } + updated := *snapshot + if health.Status != "" { + updated.DistributionStatus = health.Status + } + if health.StatusCode != "" { + updated.DistributionStatusCode = health.StatusCode + } + return &updated +} + func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { if len(raw) == 0 || string(raw) == "null" { return nil, "", false diff --git a/internal/api/server.go b/internal/api/server.go index d20b194..3c6b95e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "log" "net/http" "strconv" @@ -18,9 +19,10 @@ type Server struct { logger *log.Logger limiter *tokenBucketLimiter - mu sync.RWMutex - idx *ReleaseIndex - meta DiscoverResult + refreshMu sync.Mutex + mu sync.RWMutex + idx *ReleaseIndex + meta DiscoverResult refreshInProgress bool lastRefreshStart time.Time @@ -95,10 +97,46 @@ func (s *Server) Handler() http.Handler { // Refresh rebuilds the release index via RPC (and optional resource-root override). func (s *Server) Refresh(ctx context.Context) error { + // Serialize refreshes so an older, slower RPC response cannot replace a + // newer snapshot and so refresh diagnostics describe one attempt at a time. + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + started := s.beginRefresh() result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot) if err != nil { - s.finishRefresh(started, err, nil) + s.mu.Lock() + warnings := []string(nil) + if result != nil { + s.meta = *result + warnings = append(warnings, result.Warnings...) + } else { + s.meta = DiscoverResult{} + } + warnings = append(warnings, fmt.Sprintf("refresh: %v", err)) + s.idx = &ReleaseIndex{ + Source: "empty", + Distribution: unavailableRustDistributionHealth(), + byRel: map[string]int{}, + } + s.meta.Index = s.idx + s.meta.ResourceRoot = "" + s.meta.Distribution = s.idx.Distribution + s.finishRefreshLocked(started, err, warnings) + s.mu.Unlock() + return err + } + if result == nil || result.Index == nil { + err := fmt.Errorf("refresh returned no release index") + s.mu.Lock() + s.idx = &ReleaseIndex{ + Source: "empty", + Distribution: unavailableRustDistributionHealth(), + byRel: map[string]int{}, + } + s.meta = DiscoverResult{Index: s.idx, Distribution: s.idx.Distribution} + s.finishRefreshLocked(started, err, []string{err.Error()}) + s.mu.Unlock() return err } s.mu.Lock() @@ -229,6 +267,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { Release: sum.Snapshot, ResourceRoot: sum.ResourceRoot, Source: sum.Source, + Distribution: sum.Distribution, ManifestVersion: sum.ManifestVersion, EntryCount: sum.EntryCount, PresentCount: sum.PresentCount, @@ -287,6 +326,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { "missing_count": sum.MissingCount, "source": sum.Source, "warnings": meta.Warnings, + "distribution": sum.Distribution, // Database/redis are reserved config surface for a normal API process. "database_configured": s.cfg.DatabaseURL != "", "redis_configured": s.cfg.RedisURL != "", @@ -316,6 +356,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { "present_count": sum.PresentCount, "missing_count": sum.MissingCount, "source": sum.Source, + "distribution": sum.Distribution, "refresh": s.refreshSnapshot(), } if r.Method == http.MethodHead { @@ -430,12 +471,6 @@ func (s *Server) beginRefresh() time.Time { return now } -func (s *Server) finishRefresh(started time.Time, err error, warnings []string) { - s.mu.Lock() - defer s.mu.Unlock() - s.finishRefreshLocked(started, err, warnings) -} - func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) { now := time.Now() s.refreshInProgress = false diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 2231a3b..eec3443 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -38,7 +38,7 @@ func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) tes client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) { clientConn, serverConn := net.Pipe() go func(conn net.Conn) { - defer conn.Close() + defer func() { _ = conn.Close() }() line, err := bufio.NewReader(conn).ReadBytes('\n') if err != nil { return @@ -137,6 +137,45 @@ func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) { } } +func TestReleaseStatusUsesWholeReleaseHealthFact(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "release.status" { + t.Fatalf("method = %s", req.Method) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + Data: map[string]any{ + "status": "blocked", + "status_code": "distribution.blocked", + "default_distribution_channel": "official", + "official_current_release_id": "official-1", + "official_distribution_ready": false, + "releases": []any{map[string]any{ + "channel": "official", + "id": "official-1", + "current": true, + "distribution_integrity_status": "invalid", + }}, + }, + }, + } + }) + + report, err := client.ReleaseStatus(context.Background()) + if err != nil { + t.Fatalf("ReleaseStatus error: %v", err) + } + if report.StatusCode != "distribution.blocked" || + report.OfficialDistributionReady || + report.OfficialCurrentReleaseID != "official-1" || + len(report.Releases) != 1 || + report.Releases[0].DistributionIntegrityStatus != "invalid" { + t.Fatalf("report = %#v", report) + } +} + func TestDaemonRestartSendsControlMethod(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "daemon.restart" { diff --git a/scripts/check-doc-status.sh b/scripts/check-doc-status.sh index 47291c0..f7dd70d 100644 --- a/scripts/check-doc-status.sh +++ b/scripts/check-doc-status.sh @@ -141,12 +141,25 @@ require_contains "docs/reports/CURRENT_GAPS.md" "daemon.clean-stable" require_contains "docs/reference/rpc-backend-api.md" "daemon.restart" require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable" require_contains "docs/reference/rpc-backend-api.md" "localized_release_status" +require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready" +require_contains "docs/architecture/resource-release-layout.md" "release.status" require_contains "Makefile" "check-docs:" +require_contains "Makefile" "format: fmt" +require_contains "Makefile" "ci-check:" +require_contains "Makefile" "ci: ci-check" +require_file "scripts/ci-check.sh" +require_contains "scripts/ci-check.sh" "RUN required:" +require_contains "scripts/ci-check.sh" "SKIP optional:" +if grep -Fq "ci: fmt" Makefile; then + fail "Makefile ci target must not run the mutating fmt target" +fi require_contains ".gitea/workflows/bat.yml" "make check-docs" require_contains ".gitea/workflows/bat.yml" "make test-go-api" require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..." require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api" +require_contains ".gitea/workflows/bat.yml" "Check Go formatting" +require_contains ".gitea/workflows/bat.yml" "SKIP optional: Go lint" require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings" openapi_tmp="$(mktemp)" diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh new file mode 100644 index 0000000..09d0dc3 --- /dev/null +++ b/scripts/ci-check.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +: "${GOCACHE:=/tmp/bat-go-cache}" +export GOCACHE +: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}" +export XDG_CACHE_HOME + +run_required() { + local name="$1" + shift + printf 'RUN required: %s\n' "${name}" + "$@" + printf 'PASS required: %s\n' "${name}" +} + +check_gofmt() { + local -a go_dirs=() + local -a go_files=() + local dir file formatted dirs_file + dirs_file="$(mktemp)" + if ! go list -f '{{.Dir}}' ./... >"${dirs_file}"; then + rm -f "${dirs_file}" + return 1 + fi + mapfile -t go_dirs <"${dirs_file}" + rm -f "${dirs_file}" + for dir in "${go_dirs[@]}"; do + for file in "${dir}"/*.go; do + [[ -f "${file}" ]] && go_files+=("${file}") + done + done + if ((${#go_files[@]} == 0)); then + return + fi + formatted="$(gofmt -l "${go_files[@]}")" + if [[ -n "${formatted}" ]]; then + printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2 + return 1 + fi +} + +run_required "Rust formatting" cargo fmt --all -- --check +run_required "Go formatting" check_gofmt +run_required "Rust check" cargo check --workspace --locked +run_required "Rust release build" cargo build --workspace --release --locked +run_required "Rust clippy" cargo clippy --workspace --all-targets --locked -- -D warnings +run_required "Rust tests" cargo test --workspace --locked +run_required "Go API tests" go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... +run_required "Go API vet" go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... +run_required "Go API build" go build -o /tmp/bat-api ./cmd/bat-api +run_required "Documentation, OpenAPI, and contract checks" make check-docs + +optional_failed=0 +if command -v golangci-lint >/dev/null 2>&1; then + printf 'RUN optional: Go lint (golangci-lint)\n' + if golangci-lint run ./...; then + printf 'PASS optional: Go lint (golangci-lint)\n' + else + printf 'FAIL optional: Go lint (golangci-lint); reason=lint findings\n' >&2 + optional_failed=1 + fi +else + printf 'SKIP optional: Go lint (golangci-lint); reason=command not installed\n' +fi + +if ((optional_failed)); then + printf 'required check-only gates passed; optional gates failed\n' >&2 + exit 1 +fi + +printf 'required check-only gates passed; optional gates are reported above\n'