From 885bd828718fb19a509c6f0d5bf32ea551439454 Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 16:01:17 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E5=8D=87=E7=BA=A7=20grpc-gateway=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE=E8=87=B3=20v2.30.0=20=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OpenAPI=20=E5=85=AC=E5=85=B1=20ErrorRespon?= =?UTF-8?q?se=20$ref=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成脚本启用 allow_merge、显式输入 response.proto,并将合并后的 microservice.swagger.json 直接输出到 public/openapi/;同步将模板 OpenTelemetry 提升至 v1.45.0 满足依赖下限,并在模板兼容性门禁中新增 $ref 完整性与关键公共 schema 回归校验。 --- CHANGELOG/CHANGELOG-0.5.md | 12 ++++ internal/projectmigrate/compatibility_test.go | 2 +- scripts/binaries.sh | 2 +- template/compatibility_test.go | 57 +++++++++++++++++-- template/service/go.mod.tmpl | 8 +-- template/service/scripts/binaries.sh | 2 +- template/service/scripts/generate.sh.tmpl | 27 ++++++--- template/service_test.go | 30 +++++++++- 8 files changed, 118 insertions(+), 22 deletions(-) diff --git a/CHANGELOG/CHANGELOG-0.5.md b/CHANGELOG/CHANGELOG-0.5.md index e1cace9..2ca14a9 100644 --- a/CHANGELOG/CHANGELOG-0.5.md +++ b/CHANGELOG/CHANGELOG-0.5.md @@ -9,6 +9,18 @@ | Fixed | 功能的修复 | | Security | 有关安全问题的修复 | +## [Unreleased] + +### Changed + +- 将 CLI 与新服务模板使用的 `protoc-gen-grpc-gateway`、`protoc-gen-openapiv2`、grpc-gateway runtime 和 Docker include 源统一升级到 `v2.30.0`;同步将模板直接使用的 OpenTelemetry 模块提升到 `v1.45.0`,满足新版本依赖下限。 + +### Fixed + +- 修复服务模板在 `protoc-gen-openapiv2 v2.30.0` 下生成的 4xx/5xx 公共 `ErrorResponse` `$ref` 无法解析的问题:生成脚本启用 `allow_merge`,显式输入 `${GOPATH}/src/github.com/grpc-kit/api/known/status/v1/response.proto`,并将合并后的 `microservice.swagger.json` 直接输出到 `public/openapi/`;新增 `$ref` 完整性与关键公共 schema 回归门禁。 +- 该问题的已知版本边界为 grpc-gateway `v2.27.5` 起;`v2.27.4` 仅用于说明问题边界,不作为实施或应急回滚方案,模板升级并固定使用当前 `v2.30.0` 工具链。 +- HTTP 路由继续独立维护在 `microservice.gateway.yaml`,运行时错误响应契约和 gateway 代码不变。官方 `protoc-gen-openapiv3` 当前不读取该外部配置,因此 OpenAPI v3 条件生成延后,不随本次修复发布。 + ## [0.5.0] - 2026-09-16 ### Added diff --git a/internal/projectmigrate/compatibility_test.go b/internal/projectmigrate/compatibility_test.go index c65a958..b4efb4e 100644 --- a/internal/projectmigrate/compatibility_test.go +++ b/internal/projectmigrate/compatibility_test.go @@ -68,7 +68,7 @@ func TestCompatibilityAssetsCompileAgainstReleasedPkg(t *testing.T) { go ` + currentTargetGoVersion + ` require ( - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 github.com/grpc-kit/pkg ` + pkgVersion + ` github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 diff --git a/scripts/binaries.sh b/scripts/binaries.sh index 2e58f17..929f61d 100755 --- a/scripts/binaries.sh +++ b/scripts/binaries.sh @@ -5,7 +5,7 @@ set -euo pipefail readonly PROTOC_VERSION="21.12" readonly PROTOC_GEN_GO_VERSION="v1.36.11" readonly PROTOC_GEN_GO_GRPC_VERSION="v1.6.2" -readonly GRPC_GATEWAY_VERSION="v2.29.0" +readonly GRPC_GATEWAY_VERSION="v2.30.0" verify_sha256() { local checksum=$1 archive=$2 diff --git a/template/compatibility_test.go b/template/compatibility_test.go index 23962ca..f130f55 100644 --- a/template/compatibility_test.go +++ b/template/compatibility_test.go @@ -10,6 +10,7 @@ package template import ( "context" + "encoding/json" "os" "os/exec" "path/filepath" @@ -82,11 +83,8 @@ func TestGeneratedServiceCompilesAgainstReleasedPkg(t *testing.T) { t.Fatalf("go env GOPATH: %v", err) } environment := append(os.Environ(), "GOPATH="+strings.TrimSpace(string(gopathOutput)), "GOWORK=off") - for _, command := range [][]string{ - {"make", "generate"}, - {"go", "test", "./...", "-count=1"}, - {"go", "build", "./..."}, - } { + run := func(command ...string) { + t.Helper() process := exec.CommandContext(ctx, command[0], command[1:]...) process.Dir = root process.Env = environment @@ -94,4 +92,53 @@ func TestGeneratedServiceCompilesAgainstReleasedPkg(t *testing.T) { t.Fatalf("%s: %v\n%s", strings.Join(command, " "), runErr, output) } } + run("make", "generate") + + swaggerBody, err := os.ReadFile(filepath.Join(root, "public", "openapi", "microservice.swagger.json")) + if err != nil { + t.Fatalf("read generated OpenAPI v2 document: %v", err) + } + var swagger map[string]any + if err = json.Unmarshal(swaggerBody, &swagger); err != nil { + t.Fatalf("parse generated OpenAPI v2 document: %v", err) + } + definitions, ok := swagger["definitions"].(map[string]any) + if !ok { + t.Fatal("generated OpenAPI v2 document has no definitions object") + } + for _, name := range []string{"v1ErrorResponse", "statusV1Status", "protobufAny"} { + if _, exists := definitions[name]; !exists { + t.Errorf("generated OpenAPI v2 document is missing definition %q", name) + } + } + var validateRefs func(any) + validateRefs = func(value any) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if key == "$ref" { + ref, refOK := child.(string) + if !refOK || !strings.HasPrefix(ref, "#/definitions/") { + t.Errorf("generated OpenAPI v2 document contains non-local reference %v", child) + continue + } + name := strings.TrimPrefix(ref, "#/definitions/") + if _, exists := definitions[name]; !exists { + t.Errorf("generated OpenAPI v2 document contains unresolved reference %q", ref) + } + continue + } + validateRefs(child) + } + case []any: + for _, child := range typed { + validateRefs(child) + } + } + } + validateRefs(swagger) + t.Log("validated generated OpenAPI v2 document and local schema references") + + run("go", "test", "./...", "-count=1") + run("go", "build", "./...") } diff --git a/template/service/go.mod.tmpl b/template/service/go.mod.tmpl index b1acdbd..d3f1485 100644 --- a/template/service/go.mod.tmpl +++ b/template/service/go.mod.tmpl @@ -4,14 +4,14 @@ go 1.25.13 require ( entgo.io/ent v0.14.5 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 github.com/grpc-kit/pkg v0.5.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 google.golang.org/grpc v1.83.1 google.golang.org/protobuf v1.36.11 k8s.io/client-go v0.34.1 diff --git a/template/service/scripts/binaries.sh b/template/service/scripts/binaries.sh index 2e58f17..929f61d 100755 --- a/template/service/scripts/binaries.sh +++ b/template/service/scripts/binaries.sh @@ -5,7 +5,7 @@ set -euo pipefail readonly PROTOC_VERSION="21.12" readonly PROTOC_GEN_GO_VERSION="v1.36.11" readonly PROTOC_GEN_GO_GRPC_VERSION="v1.6.2" -readonly GRPC_GATEWAY_VERSION="v2.29.0" +readonly GRPC_GATEWAY_VERSION="v2.30.0" verify_sha256() { local checksum=$1 archive=$2 diff --git a/template/service/scripts/generate.sh.tmpl b/template/service/scripts/generate.sh.tmpl index db8d2de..7483a0f 100755 --- a/template/service/scripts/generate.sh.tmpl +++ b/template/service/scripts/generate.sh.tmpl @@ -1,12 +1,20 @@ #!/bin/bash +set -e + source scripts/env function gen_protoc() { + mkdir -p ./public/openapi + rm -f ./public/openapi/microservice.swagger.json + test -f "${GOPATH}"/src/github.com/grpc-kit/api/known/status/v1/response.proto + # 生成 *.pb.go 文件 + # 同时兼容新旧工具链的 well-known types 位置:优先 ${GOPATH}/include(binaries.sh 安装,与 protoc 版本配对),回退 /usr/local/include(v0.3.8 旧版安装位置) protoc \ -I ./ \ -I "${GOPATH}"/include/ \ + -I /usr/local/include/ \ -I "${GOPATH}"/src \ -I "${GOPATH}"/src/github.com/grpc-ecosystem/grpc-gateway/ \ -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ @@ -20,6 +28,7 @@ function gen_protoc() { # 生成 *.pb.gw.go 与 swagger 接口文档 protoc \ -I ./ \ + -I "${GOPATH}"/include/ \ -I /usr/local/include/ \ -I "${GOPATH}"/src \ -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ @@ -31,15 +40,15 @@ function gen_protoc() { --openapiv2_opt disable_service_tags=true \ --openapiv2_opt grpc_api_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml \ --openapiv2_opt openapi_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml \ - --openapiv2_out=json_names_for_fields=false:./ \ - ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.proto - - # 移动生成的 microservice.swagger.json 文件 - if test -f ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.swagger.json; then - cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml ./public/openapi/ - cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml ./public/openapi/ - mv ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.swagger.json ./public/openapi/ - fi + --openapiv2_opt allow_merge=true \ + --openapiv2_opt merge_file_name=microservice \ + --openapiv2_out=json_names_for_fields=false:./public/openapi \ + ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.proto \ + "${GOPATH}"/src/github.com/grpc-kit/api/known/status/v1/response.proto + + test -s ./public/openapi/microservice.swagger.json + cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml ./public/openapi/ + cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml ./public/openapi/ } function gen_ent() { diff --git a/template/service_test.go b/template/service_test.go index 6361fed..624d0fa 100644 --- a/template/service_test.go +++ b/template/service_test.go @@ -79,6 +79,34 @@ func TestServiceTemplateRendersExtensions(t *testing.T) { } } } + assertContains( + "scripts/generate.sh", + "set -e", + "--grpc-gateway_opt grpc_api_configuration=", + "--openapiv2_opt grpc_api_configuration=", + "--openapiv2_opt allow_merge=true", + "--openapiv2_opt merge_file_name=microservice", + "--openapiv2_out=json_names_for_fields=false:./public/openapi", + `"${GOPATH}"/src/github.com/grpc-kit/api/known/status/v1/response.proto`, + "rm -f ./public/openapi/microservice.swagger.json", + "test -s ./public/openapi/microservice.swagger.json", + "microservice.gateway.yaml ./public/openapi/", + ) + assertNotContains( + "scripts/generate.sh", + "mv ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.swagger.json", + "protoc-gen-openapiv3", + "--openapiv3_out", + ) + assertContains( + "api/demo/echo/v1/microservice.gateway.yaml", + "type: google.api.Service", + `get: "/api/healthz"`, + `post: "/api/demo"`, + `put: "/api/demo/{uuid}"`, + `delete: "/api/demo/{uuid}"`, + ) + assertNotContains("api/demo/echo/v1/microservice.proto", `import "google/api/annotations.proto";`, "option (google.api.http)") assertContains("handler/register.go", "DO NOT EDIT", "privateMCPHandle()", "HTTPHandlerFrontend(ctx", "StartBackground(ctx)") assertNotContains("handler/register.go", "HTTPHandlerFrontendContext(ctx", "StartBackground()") assertContains("public/embed.go", `// Code generated by "grpc-kit-cli/0.0.1-test". DO NOT EDIT.`, "var Assets embed.FS") @@ -97,7 +125,7 @@ func TestServiceTemplateRendersExtensions(t *testing.T) { assertContains("modeler/mcp/registrar.go", "func (r *Registrar) Register", "server is nil") assertContains("modeler/mcp/registrar_test.go", "session.CallTool", "session.ReadResource", "session.GetPrompt") assertContains("config/app-dev-local.yaml", "aiconnector:", "mcp_server:") - assertContains("go.mod", "go 1.25.13", "github.com/grpc-kit/pkg v0.5.0", "github.com/modelcontextprotocol/go-sdk v1.7.0", "google.golang.org/grpc v1.83.1") + assertContains("go.mod", "go 1.25.13", "github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0", "github.com/grpc-kit/pkg v0.5.0", "github.com/modelcontextprotocol/go-sdk v1.7.0", "go.opentelemetry.io/otel v1.45.0", "google.golang.org/grpc v1.83.1") assertNotContains("go.mod", "github.com/sirupsen/logrus") assertContains("Makefile", ">> synchronize Go module dependencies", "@${GO} mod tidy") assertContains("AGENTS.md", "## Shared Skills", "scripts/skills/skills/generate-release-changelog/SKILL.md") From 86d67b6a37d0980c8e11d407a65edcd2838e791a Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 16:01:43 +0800 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=20Dockerfile=20?= =?UTF-8?q?=E5=86=85=20grpc-gateway=20proto=20=E5=AE=9A=E4=B9=89=E8=87=B3?= =?UTF-8?q?=20v2.30.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index eca41b5..1c151fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ RUN go mod download \ && git -C $GOPATH/src/github.com/googleapis/googleapis remote add origin https://github.com/googleapis/googleapis.git \ && git -C $GOPATH/src/github.com/googleapis/googleapis fetch --depth 1 origin e0d0106516a5c613510533821e4508bc6c943b11 \ && git -C $GOPATH/src/github.com/googleapis/googleapis checkout --detach FETCH_HEAD \ - && git clone -b v2.29.0 --depth 1 https://github.com/grpc-ecosystem/grpc-gateway.git $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway + && git clone -b v2.30.0 --depth 1 https://github.com/grpc-ecosystem/grpc-gateway.git $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway # 用于 go 应用的编译 FROM --platform=$TARGETPLATFORM ${GO_IMAGE} From c52dc1a420992ed0ad5c32757a8daf03227c7ffb Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 20:13:42 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=20scripts/genera?= =?UTF-8?q?te.sh=20=E7=AC=AC=E4=BA=8C=E8=A1=8C=20marker=20=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=8F=8A=200.5.1=20=E5=89=8D=E6=97=A0=20marker=20?= =?UTF-8?q?=E5=AD=98=E9=87=8F=E5=89=AF=E6=9C=AC=E7=9A=84=20SHA-256=20?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E6=91=98=E8=A6=81=E7=AA=84=E4=BE=8B=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/maintain-project-migration/SKILL.md | 2 +- .agents/skills/maintain-project-migration/references/sop.md | 1 + README.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.agents/skills/maintain-project-migration/SKILL.md b/.agents/skills/maintain-project-migration/SKILL.md index 9556c61..a24bf18 100644 --- a/.agents/skills/maintain-project-migration/SKILL.md +++ b/.agents/skills/maintain-project-migration/SKILL.md @@ -17,7 +17,7 @@ description: 维护 grpc-kit CLI 服务模板、project migrate 兼容资产和 始终保持以下约束: - `TargetCLIVersion` 只表示执行迁移的正式 CLI 版本;pkg 目标版本独立记录。 -- 只有首行完整 grpc-kit-cli `DO NOT EDIT` marker 的已有普通文件可进入 Change Plan。 +- 只有首行完整 grpc-kit-cli `DO NOT EDIT` marker 的已有普通文件可进入 Change Plan;`scripts/generate.sh` 的 marker 位于第二行(首行是 shebang),0.5.1 之前无 marker 的存量世代凭"路径精确 + 全文 SHA-256 匹配冻结摘要"的内容证据窄例外整文件托管,维护规则见 SOP §3。 - 不自动修改用户代码、`go.mod`、`go.sum`,不创建、删除、重命名或 chmod 项目文件。 - 用户可以另行使用共享技能 `migrate-logrus-to-slog` 完成业务源码迁移;该授权属于 AI 编辑工作流,不得反向扩大 `project migrate` 的 Change Plan。 - 兼容资产只引用来源 fixture 已有的项目内符号;新功能走新建模板或独立 feature 流程。 diff --git a/.agents/skills/maintain-project-migration/references/sop.md b/.agents/skills/maintain-project-migration/references/sop.md index 0b973b7..08f5980 100644 --- a/.agents/skills/maintain-project-migration/references/sop.md +++ b/.agents/skills/maintain-project-migration/references/sop.md @@ -33,6 +33,7 @@ 4. 新增 pkg 迁移目标时保留旧目标资产和回归测试。不要原地把 `v0.5.0` 资产目录解释成 `v0.6.0`。 5. 用户管理文件中的破坏点进入 `ManualAction`。诊断应提供稳定 code、文件、行号和建议,并排除本次 Change Plan 已整体替换的路径。 6. logrus/slog 用户源码可以推荐使用 grpc-kit 共享技能 `migrate-logrus-to-slog`。技能由用户显式授权 AI 编辑并负责语义迁移、依赖清理和完整项目验证;它不是 CLI 自动写入能力,也不能让未托管文件进入 Change Plan。 +7. `scripts/generate.sh` 的 marker 位于第二行(首行是 shebang),自 0.5.1 起服务模板与迁移产物都携带它。在此之前生成的副本没有 marker,属于 marker 规则的唯一内容证据窄例外:托管仅凭"路径精确 + 全文 SHA-256 命中冻结摘要"授权整文件改写,写回内容为按 `{{ .TargetCLIVersion }}` 渲染的最新模板资产(嵌入式资产须与 `template/service/scripts/generate.sh.tmpl` 的渲染结果逐字节一致,有测试门禁),并补上第二行 marker。历史摘要只收纳无 marker 世代(v0.3.8 已冻结;v0.3.9-beta.1 因离线无法核对 tag 内容暂未冻结,此类副本走 `generate_script_modified` ManualAction 兜底)。未知内容不进入 Change Plan,只提示人工核对;文件缺失不创建。后续修改 `generate.sh.tmpl` 时必须同步重冻结嵌入资产并更新 `generateScriptAssetSHA256`;对已携带 marker 的 0.5.1+ 世代,后续版本应把 marker 识别扩展到"shebang + 第二行"走常规托管,不再向 legacy 摘要表积累条目。该规则也在 `internal/projectmigrate/scriptpatch.go` 文件头注释中记录。 ## 4. 正式 pkg 兼容矩阵 diff --git a/README.md b/README.md index 64b44ae..605f4d0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ grpc-kit-cli project migrate ./service grpc-kit-cli project migrate ./service --apply ``` -命令只会更新首行带有完整 `Code generated by "grpc-kit-cli/...". DO NOT EDIT.` 标记、且存在内嵌兼容资产的已有文件;不会修改 `go.mod`、业务源码或其他用户管理文件,也不会创建、删除或重命名文件。`Manual action` 表示仍需完成的 Go 版本、pkg、logrus/slog 或 context API 迁移,因此“托管文件已迁移”不等于“项目已通过编译验证”。根级 `upgrade`/`update` 不用于项目迁移;未来 CLI 自身更新保留 `self update` 语义。 +命令只会更新首行带有完整 `Code generated by "grpc-kit-cli/...". DO NOT EDIT.` 标记、且存在内嵌兼容资产的已有文件;`scripts/generate.sh` 的标记位于第二行(首行是 shebang),0.5.1 之前的存量副本没有标记,迁移凭"路径精确 + 全文 SHA-256 匹配冻结历史版本"的内容证据整文件同步到最新服务模板并补上标记,本地修改过或内容不明的副本只会得到 `generate_script_modified` 人工核对提示而不被覆盖,文件缺失则不创建。命令不会修改 `go.mod`、业务源码或其他用户管理文件,也不会创建、删除或重命名文件。`Manual action` 表示仍需完成的 Go 版本、pkg、logrus/slog 或 context API 迁移,因此“托管文件已迁移”不等于“项目已通过编译验证”。根级 `upgrade`/`update` 不用于项目迁移;未来 CLI 自身更新保留 `self update` 语义。 旧服务的用户源码可使用 grpc-kit 共享技能 `migrate-logrus-to-slog` 进行 AI 辅助迁移;安装共享技能后入口为 `scripts/skills/skills/migrate-logrus-to-slog/SKILL.md`。该技能可以在用户明确授权后修改业务源码和依赖,但不会放宽 `project migrate` 的 marker 写入规则,迁移结果仍需经过 diff 审阅、生成、测试和构建。 From 574377264c5dd365d385f2ba27a766c90cc99148 Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 20:15:24 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=8E=A5=E7=AE=A1=20scripts/generate.sh=EF=BC=8C?= =?UTF-8?q?=E5=86=BB=E7=BB=93=E6=91=98=E8=A6=81=E7=B2=BE=E7=A1=AE=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E7=9A=84=E5=AD=98=E9=87=8F=E5=89=AF=E6=9C=AC=E6=95=B4?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=87=8D=E5=86=99=EF=BC=8C=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=99=8D=E7=BA=A7=E4=B8=BA=E4=BA=BA=E5=B7=A5?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/projectmigrate/diagnostics.go | 7 +- internal/projectmigrate/plan.go | 29 ++++- internal/projectmigrate/scriptpatch.go | 170 +++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 internal/projectmigrate/scriptpatch.go diff --git a/internal/projectmigrate/diagnostics.go b/internal/projectmigrate/diagnostics.go index fffd027..601e129 100644 --- a/internal/projectmigrate/diagnostics.go +++ b/internal/projectmigrate/diagnostics.go @@ -36,7 +36,9 @@ var printfLoggerMethods = map[string]struct{}{ // ScanManualActions reports known pkg v0.5.0 incompatibilities without // modifying source. Paths already replaced by the managed plan are excluded. -func ScanManualActions(project Project, changedPaths map[string]struct{}) ([]Diagnostic, error) { +// targetCLIVersion is the migration target used to recognize up-to-date +// generate.sh content. +func ScanManualActions(project Project, changedPaths map[string]struct{}, targetCLIVersion string) ([]Diagnostic, error) { actions, err := scanGoModActions(project) if err != nil { return nil, err @@ -45,6 +47,9 @@ func ScanManualActions(project Project, changedPaths map[string]struct{}) ([]Dia for _, action := range actions { dedup[diagnosticKey(action)] = struct{}{} } + if err := scanGenerateScriptAction(project, changedPaths, &actions, dedup, targetCLIVersion); err != nil { + return nil, err + } err = filepath.WalkDir(project.Root, func(currentPath string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr diff --git a/internal/projectmigrate/plan.go b/internal/projectmigrate/plan.go index 65db939..4658ea9 100644 --- a/internal/projectmigrate/plan.go +++ b/internal/projectmigrate/plan.go @@ -95,7 +95,7 @@ func BuildPlan(projectPath, targetCLIVersion string) (Plan, error) { Path: "scripts/env", Message: fmt.Sprintf("source CLI version %s has no frozen compatibility assets", project.SourceCLIVersion), }) - plan.ManualActions, _ = ScanManualActions(project, nil) + plan.ManualActions, _ = ScanManualActions(project, nil, targetVersion) return plan, nil } project.SourceFamily = "v0.3.8" @@ -121,27 +121,46 @@ func BuildPlan(projectPath, targetCLIVersion string) (Plan, error) { } relativePath = filepath.ToSlash(relativePath) _, isAsset := assetSet[relativePath] + isScriptPatch := relativePath == generateScriptPath if entry.IsDir() { if entry.Name() == ".git" { return filepath.SkipDir } - if isAsset || relativePath == legacyPublicEmbedPath { + if isAsset || relativePath == legacyPublicEmbedPath || isScriptPatch { plan.Conflicts = append(plan.Conflicts, Diagnostic{Code: "unsafe_file", Path: relativePath, Message: "managed path is a directory"}) } return nil } if entry.Type()&os.ModeSymlink != 0 { - if isAsset || relativePath == legacyPublicEmbedPath { + if isAsset || relativePath == legacyPublicEmbedPath || isScriptPatch { plan.Conflicts = append(plan.Conflicts, Diagnostic{Code: "unsafe_file", Path: relativePath, Message: "managed path is a symlink"}) } return nil } if !entry.Type().IsRegular() { - if isAsset { + if isAsset || isScriptPatch { plan.Conflicts = append(plan.Conflicts, Diagnostic{Code: "unsafe_file", Path: relativePath, Message: "managed path is not a regular file"}) } return nil } + if isScriptPatch { + body, err := readRegularFile(currentPath, maxManagedFileSize) + if err != nil { + return fmt.Errorf("read managed file %s: %w", relativePath, err) + } + info, err := entry.Info() + if err != nil { + return err + } + target, err := renderGenerateScriptTarget(targetVersion) + if err != nil { + return fmt.Errorf("render managed file %s: %w", relativePath, err) + } + if change, _ := planGenerateScript(body, info.Mode(), target); change != nil { + plan.Changes = append(plan.Changes, *change) + } + return nil + } line, err := readFirstLine(currentPath) if err != nil { @@ -240,7 +259,7 @@ func BuildPlan(projectPath, targetCLIVersion string) (Plan, error) { for _, change := range plan.Changes { changedPaths[change.Path] = struct{}{} } - manualActions, diagnosticsErr := ScanManualActions(project, changedPaths) + manualActions, diagnosticsErr := ScanManualActions(project, changedPaths, targetVersion) if diagnosticsErr != nil { plan.Warnings = append(plan.Warnings, Diagnostic{Code: "diagnostics_failed", Message: diagnosticsErr.Error()}) } else { diff --git a/internal/projectmigrate/scriptpatch.go b/internal/projectmigrate/scriptpatch.go new file mode 100644 index 0000000..4b6ec74 --- /dev/null +++ b/internal/projectmigrate/scriptpatch.go @@ -0,0 +1,170 @@ +// Copyright © 2026 The gRPC Kit Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package projectmigrate + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io/fs" + "path/filepath" + "text/template" + + _ "embed" +) + +const generateScriptPath = "scripts/generate.sh" + +const ( + // generateScriptAssetSHA256 pins the embedded frozen asset, which carries + // the {{ .TargetCLIVersion }} marker expression. Whenever + // template/service/scripts/generate.sh.tmpl changes, re-freeze + // assets/v0_5_0/scripts/generate.sh.tmpl and update this digest. + generateScriptAssetSHA256 = "e911a752d48765d560902873785ae04c72aa6fcfcb0bd0e31372aeae449e221e" + // legacyGenerateScriptV038SHA256 pins the frozen v0.3.8 template blob. + // The v0.3.8 scaffold rendered this .tmpl through text/template with no + // template expressions and no leading-trim effect, so the blob is + // byte-identical to the scripts/generate.sh found in v0.3.8 projects. + legacyGenerateScriptV038SHA256 = "8ca0626c1f4a5e35ac1004557f662410c63a1fa5402a5916044c59dbff101c48" +) + +// scripts/generate.sh keeps its shebang on line 1, so since CLI 0.5.1 the +// service template puts the ownership marker on line 2. Pre-0.5.1 scaffolds +// have no marker at all; those generations stay under the one narrow +// content-evidence exception to the first-line marker rule (see the +// maintain-project-migration SOP, "修复生成器缺陷"): a whole-file rewrite is +// authorized only when the on-disk content matches a frozen digest exactly. +// Content similarity never grants ownership. Marker-carrying copies produced +// by 0.5.1 scaffolds and migrations should migrate through the marker rule in +// future releases (recognizing the shebang+line-2 shape for this path) instead +// of growing legacyGenerateScriptSHA256. + +//go:embed assets/v0_5_0/scripts/generate.sh.tmpl +var generateScriptAsset []byte + +// legacyGenerateScriptSHA256 maps exact frozen legacy digests to the CLI +// source family that produced them. It only absorbs pre-0.5.1 generations +// without a marker. Source families without a locally available release +// artifact (v0.3.9-beta.1) stay unmapped and surface as manual actions +// instead of rewrites. +var legacyGenerateScriptSHA256 = map[string]string{ + legacyGenerateScriptV038SHA256: "v0.3.8", +} + +// renderGenerateScriptTarget renders the frozen asset with the target CLI +// version and validates the shebang plus the line-2 marker, mirroring the +// guarantees RenderCompatibilityAsset enforces for marker assets. +func renderGenerateScriptTarget(targetCLIVersion string) ([]byte, error) { + version, err := normalizeTargetCLIVersion(targetCLIVersion) + if err != nil { + return nil, fmt.Errorf("target CLI version: %w", err) + } + parsed, err := template.New(generateScriptPath).Option("missingkey=error").Parse(string(generateScriptAsset)) + if err != nil { + return nil, fmt.Errorf("parse generate script asset: %w", err) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, assetData{TargetCLIVersion: version}); err != nil { + return nil, fmt.Errorf("render generate script target: %w", err) + } + result := rendered.Bytes() + if bytes.Contains(result, []byte("{{")) || bytes.Contains(result, []byte("}}")) { + return nil, fmt.Errorf("render generate script target: unresolved template expression") + } + if !bytes.HasPrefix(result, []byte("#!/bin/bash\n")) { + return nil, fmt.Errorf("render generate script target: missing shebang") + } + marker, err := ParseMarkerLine(secondLine(result)) + if err != nil || marker.Version != version { + return nil, fmt.Errorf("render generate script target: invalid target marker") + } + return result, nil +} + +// generateScriptEvidence classifies on-disk scripts/generate.sh content by +// exact SHA-256 (frozen legacy generations) or byte equality with the +// rendered target (marker-carrying current content). +type generateScriptEvidence int + +const ( + generateScriptUnknown generateScriptEvidence = iota + generateScriptCurrent + generateScriptLegacy +) + +func classifyGenerateScript(body, target []byte) generateScriptEvidence { + digest := fmt.Sprintf("%x", sha256.Sum256(body)) + if _, ok := legacyGenerateScriptSHA256[digest]; ok { + return generateScriptLegacy + } + if bytes.Equal(body, target) { + return generateScriptCurrent + } + return generateScriptUnknown +} + +// planGenerateScript authorizes a whole-file rewrite of scripts/generate.sh +// only for exact frozen legacy content; anything else is left to manual +// action so locally modified scripts are never overwritten. +func planGenerateScript(body []byte, mode fs.FileMode, target []byte) (change *Change, upToDate bool) { + switch classifyGenerateScript(body, target) { + case generateScriptCurrent: + return nil, true + case generateScriptLegacy: + return &Change{ + Path: generateScriptPath, + Before: body, + After: bytes.Clone(target), + Mode: mode, + BeforeSHA256: sha256.Sum256(body), + }, false + default: + return nil, false + } +} + +// scanGenerateScriptAction reports scripts/generate.sh when its content is +// neither the rendered target nor a frozen legacy version and the managed +// plan is not already rewriting the whole file. Absent files need nothing, +// and unsafe shapes are reported as unsafe_file conflicts by BuildPlan, so +// every read failure stays silent here. +func scanGenerateScriptAction(project Project, changedPaths map[string]struct{}, actions *[]Diagnostic, dedup map[string]struct{}, targetCLIVersion string) error { + if _, excluded := changedPaths[generateScriptPath]; excluded { + return nil + } + body, err := readRegularFile(filepath.Join(project.Root, filepath.FromSlash(generateScriptPath)), maxManagedFileSize) + if err != nil { + return nil + } + target, err := renderGenerateScriptTarget(targetCLIVersion) + if err != nil { + return fmt.Errorf("scan %s: %w", generateScriptPath, err) + } + if classifyGenerateScript(body, target) != generateScriptUnknown { + return nil + } + appendDiagnostic(actions, dedup, Diagnostic{ + Code: "generate_script_modified", + Path: generateScriptPath, + Message: "content is neither the current template nor a frozen legacy version; reconcile the protoc include paths and swagger output handling against the latest service template manually", + }) + return nil +} + +func secondLine(body []byte) string { + _, rest, _ := bytes.Cut(body, []byte("\n")) + line, _, _ := bytes.Cut(rest, []byte("\n")) + return string(line) +} From fefbcb757d2a80f0fb26790880784da841e4601d Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 20:16:39 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20v0.5.0=20gener?= =?UTF-8?q?ate.sh=20=E8=BF=81=E7=A7=BB=E8=B5=84=E4=BA=A7=E4=B8=8E=20v0.3.8?= =?UTF-8?q?=20=E5=AD=98=E9=87=8F=E6=B5=8B=E8=AF=95=E6=A0=B7=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E6=9C=8D=E5=8A=A1=E6=A8=A1=E6=9D=BF=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E8=A1=8C=20marker=20=E5=B9=B6=E5=B0=86?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC=E5=AF=B9=E9=BD=90=20grpc-k?= =?UTF-8?q?it/pkg=20v0.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/v0_5_0/scripts/generate.sh.tmpl | 64 +++++++++++++++++++ .../testdata/legacy-v0.3.8-generate.sh | 52 +++++++++++++++ template/service/go.mod.tmpl | 8 +-- template/service/scripts/generate.sh.tmpl | 1 + 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100755 internal/projectmigrate/assets/v0_5_0/scripts/generate.sh.tmpl create mode 100644 internal/projectmigrate/testdata/legacy-v0.3.8-generate.sh diff --git a/internal/projectmigrate/assets/v0_5_0/scripts/generate.sh.tmpl b/internal/projectmigrate/assets/v0_5_0/scripts/generate.sh.tmpl new file mode 100755 index 0000000..7215bf1 --- /dev/null +++ b/internal/projectmigrate/assets/v0_5_0/scripts/generate.sh.tmpl @@ -0,0 +1,64 @@ +#!/bin/bash +# Code generated by "grpc-kit-cli/{{ .TargetCLIVersion }}". DO NOT EDIT. + +set -e + +source scripts/env + +function gen_protoc() { + mkdir -p ./public/openapi + rm -f ./public/openapi/microservice.swagger.json + test -f "${GOPATH}"/src/github.com/grpc-kit/api/known/status/v1/response.proto + + # 生成 *.pb.go 文件 + # 同时兼容新旧工具链的 well-known types 位置:优先 ${GOPATH}/include(binaries.sh 安装,与 protoc 版本配对),回退 /usr/local/include(v0.3.8 旧版安装位置) + protoc \ + -I ./ \ + -I "${GOPATH}"/include/ \ + -I /usr/local/include/ \ + -I "${GOPATH}"/src \ + -I "${GOPATH}"/src/github.com/grpc-ecosystem/grpc-gateway/ \ + -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ + --go_opt paths=source_relative \ + --go_out ./ \ + --go-grpc_opt paths=source_relative \ + --go-grpc_opt require_unimplemented_servers=false \ + --go-grpc_out ./ \ + ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/*.proto + + # 生成 *.pb.gw.go 与 swagger 接口文档 + protoc \ + -I ./ \ + -I "${GOPATH}"/include/ \ + -I /usr/local/include/ \ + -I "${GOPATH}"/src \ + -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ + -I "${GOPATH}"/src/github.com/grpc-ecosystem/grpc-gateway/ \ + --grpc-gateway_opt paths=source_relative \ + --grpc-gateway_opt grpc_api_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml \ + --grpc-gateway_out ./ \ + --openapiv2_opt disable_default_errors=true \ + --openapiv2_opt disable_service_tags=true \ + --openapiv2_opt grpc_api_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml \ + --openapiv2_opt openapi_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml \ + --openapiv2_opt allow_merge=true \ + --openapiv2_opt merge_file_name=microservice \ + --openapiv2_out=json_names_for_fields=false:./public/openapi \ + ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.proto \ + "${GOPATH}"/src/github.com/grpc-kit/api/known/status/v1/response.proto + + test -s ./public/openapi/microservice.swagger.json + cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml ./public/openapi/ + cp ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml ./public/openapi/ +} + +function gen_ent() { + # 因 "go generate" 会继承 "GOOS" 与 "GOARCH" 变量,如在交叉编译环境则可能无法运行 + GOOS=${GOHOSTOS} GOARCH=${GOHOSTARCH} go generate ./modeler/ent/ +} + +if test -z $1; then + gen_protoc +else + $1 +fi diff --git a/internal/projectmigrate/testdata/legacy-v0.3.8-generate.sh b/internal/projectmigrate/testdata/legacy-v0.3.8-generate.sh new file mode 100644 index 0000000..60437b6 --- /dev/null +++ b/internal/projectmigrate/testdata/legacy-v0.3.8-generate.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +source scripts/env + +function gen_protoc() { + # 生成 *.pb.go 文件 + protoc \ + -I ./ \ + -I /usr/local/include/ \ + -I "${GOPATH}"/src \ + -I "${GOPATH}"/src/github.com/grpc-ecosystem/grpc-gateway/ \ + -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ + --go_opt paths=source_relative \ + --go_out ./ \ + --go-grpc_opt paths=source_relative \ + --go-grpc_opt require_unimplemented_servers=false \ + --go-grpc_out ./ \ + ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/*.proto + + # 生成 *.pb.gw.go 与 swagger 接口文档 + protoc \ + -I ./ \ + -I /usr/local/include/ \ + -I "${GOPATH}"/src \ + -I "${GOPATH}"/src/github.com/googleapis/googleapis/ \ + -I "${GOPATH}"/src/github.com/grpc-ecosystem/grpc-gateway/ \ + --grpc-gateway_opt paths=source_relative \ + --grpc-gateway_opt grpc_api_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml \ + --grpc-gateway_out ./ \ + --openapiv2_opt disable_default_errors=true \ + --openapiv2_opt disable_service_tags=true \ + --openapiv2_opt grpc_api_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.gateway.yaml \ + --openapiv2_opt openapi_configuration=./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.openapiv2.yaml \ + --openapiv2_out=json_names_for_fields=false:./ \ + ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.proto + + # 移动生成的 microservice.swagger.json 文件 + if test -f ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.swagger.json; then + mv ./api/${PRODUCT_CODE}/${SHORT_NAME}/${API_VERSION}/microservice.swagger.json ./public/openapi/ + fi +} + +function gen_ent() { + # 因 "go generate" 会继承 "GOOS" 与 "GOARCH" 变量,如在交叉编译环境则可能无法运行 + GOOS=${GOHOSTOS} GOARCH=${GOHOSTARCH} go generate ./modeler/ent/ +} + +if test -z $1; then + gen_protoc +else + $1 +fi diff --git a/template/service/go.mod.tmpl b/template/service/go.mod.tmpl index d3f1485..b1acdbd 100644 --- a/template/service/go.mod.tmpl +++ b/template/service/go.mod.tmpl @@ -4,14 +4,14 @@ go 1.25.13 require ( entgo.io/ent v0.14.5 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/grpc-kit/pkg v0.5.0 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 - go.opentelemetry.io/otel v1.45.0 - go.opentelemetry.io/otel/metric v1.45.0 - go.opentelemetry.io/otel/trace v1.45.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 google.golang.org/grpc v1.83.1 google.golang.org/protobuf v1.36.11 k8s.io/client-go v0.34.1 diff --git a/template/service/scripts/generate.sh.tmpl b/template/service/scripts/generate.sh.tmpl index 7483a0f..52be849 100755 --- a/template/service/scripts/generate.sh.tmpl +++ b/template/service/scripts/generate.sh.tmpl @@ -1,4 +1,5 @@ #!/bin/bash +# Code generated by "grpc-kit-cli/{{ .Global.ReleaseVersion }}". DO NOT EDIT. set -e From 091cd3f6db577fc3e54c91abc545e15f92c6d598 Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 20:17:09 +0800 Subject: [PATCH 6/8] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=20generate.sh=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=AE=A1=E5=88=92=E3=80=81=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E4=B8=8E=E6=89=AB=E6=8F=8F=E6=B5=8B=E8=AF=95=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=AD=98=E9=87=8F=E9=87=8D=E5=86=99=E3=80=81=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E4=B8=8E=E4=BA=BA=E5=B7=A5=E5=8A=A8=E4=BD=9C=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=E5=9C=BA=E6=99=AF=EF=BC=8C=E6=A8=A1=E6=9D=BF=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E8=A1=A5=E9=BD=90=E7=AC=AC=E4=BA=8C=E8=A1=8C=20marker?= =?UTF-8?q?=20=E5=B9=B6=E5=AF=B9=E9=BD=90=20grpc-gateway=20v2.29.0=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E4=B8=8E=20binaries.sh=20v2.30.0=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/project_migrate_test.go | 17 +- internal/projectmigrate/apply_test.go | 59 +++++ internal/projectmigrate/compatibility_test.go | 2 +- internal/projectmigrate/diagnostics_test.go | 2 +- internal/projectmigrate/plan_test.go | 157 ++++++++++++ internal/projectmigrate/scriptpatch_test.go | 224 ++++++++++++++++++ template/service_test.go | 4 +- 7 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 internal/projectmigrate/scriptpatch_test.go diff --git a/cmd/project_migrate_test.go b/cmd/project_migrate_test.go index 13f9113..8eed60b 100644 --- a/cmd/project_migrate_test.go +++ b/cmd/project_migrate_test.go @@ -127,7 +127,7 @@ func TestProjectMigrateApply(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v\n%s", err, output.String()) } - if !strings.Contains(output.String(), "Managed files migrated: 1") { + if !strings.Contains(output.String(), "Managed files migrated: 2") { t.Fatalf("output = %q", output.String()) } if !strings.Contains(output.String(), "Managed files migrated; project compatibility is not yet verified.") { @@ -140,6 +140,13 @@ func TestProjectMigrateApply(t *testing.T) { if !strings.Contains(string(environment), "CLI_VERSION=0.4.0") { t.Fatalf("scripts/env was not migrated:\n%s", environment) } + generateScript, err := os.ReadFile(filepath.Join(root, "scripts", "generate.sh")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(generateScript), `# Code generated by "grpc-kit-cli/0.4.0". DO NOT EDIT.`) { + t.Fatalf("scripts/generate.sh was not migrated with the target marker:\n%s", generateScript) + } } func writeCommandTestProject(t *testing.T) string { @@ -159,6 +166,14 @@ SERVICE_CODE=${SHORT_NAME}.${API_VERSION}.${PRODUCT_CODE} if err := os.WriteFile(filepath.Join(root, "scripts", "env"), []byte(environment), 0o600); err != nil { t.Fatal(err) } + // 同步 frozen legacy fixture:v0.3.8 脚手架自带的 generate.sh 副本。 + generateScript, err := os.ReadFile(filepath.Join("..", "internal", "projectmigrate", "testdata", "legacy-v0.3.8-generate.sh")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "scripts", "generate.sh"), generateScript, 0o755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module git.lmq.io/kaopuvm/spider\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/projectmigrate/apply_test.go b/internal/projectmigrate/apply_test.go index cf05ac3..e048d83 100644 --- a/internal/projectmigrate/apply_test.go +++ b/internal/projectmigrate/apply_test.go @@ -167,6 +167,65 @@ func TestVerifyChangeInputDetectsMutation(t *testing.T) { } } +func TestApplyRewritesLegacyGenerateScript(t *testing.T) { + root := writePlanProject(t, true) + writeFile(t, filepath.Join(root, "scripts", "generate.sh"), legacyGenerateScriptFixture, 0o755) + initGitRepository(t, root) + beforeSnapshot := snapshotProject(t, root) + + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusReady || len(plan.ApplyBlockers) != 0 { + t.Fatalf("plan status = %s, blockers = %#v", plan.Status, plan.ApplyBlockers) + } + if got := changePaths(plan.Changes); len(got) != 7 { + t.Fatalf("change paths = %v, want 7 managed files", got) + } + if err := Apply(context.Background(), &plan); err != nil { + t.Fatalf("Apply() error = %v", err) + } + if plan.Status != StatusApplied { + t.Fatalf("Apply() status = %s", plan.Status) + } + afterSnapshot := snapshotProject(t, root) + assertOnlyPlannedChanges(t, beforeSnapshot, afterSnapshot, plan.Changes) + scriptBody, err := os.ReadFile(filepath.Join(root, "scripts", "generate.sh")) + if err != nil { + t.Fatal(err) + } + expected, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(scriptBody, expected) { + t.Fatal("generate.sh was not rewritten to the rendered target content") + } + marker, err := ParseMarkerLine(secondLine(scriptBody)) + if err != nil || marker.Version != "0.4.0" || marker.CommentPrefix != "#" { + t.Fatalf("migrated generate.sh marker = %#v, want script marker 0.4.0", marker) + } + info, err := os.Stat(filepath.Join(root, "scripts", "generate.sh")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("generate.sh mode = %o, want 755", info.Mode().Perm()) + } + + second, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if second.Status != StatusManagedUpToDate || len(second.Changes) != 0 { + t.Fatalf("second plan status = %s, changes = %v", second.Status, changePaths(second.Changes)) + } + if err := Apply(context.Background(), &second); err != nil { + t.Fatalf("idempotent Apply() error = %v", err) + } +} + func initGitRepository(t *testing.T, root string) { t.Helper() commands := [][]string{ diff --git a/internal/projectmigrate/compatibility_test.go b/internal/projectmigrate/compatibility_test.go index b4efb4e..c65a958 100644 --- a/internal/projectmigrate/compatibility_test.go +++ b/internal/projectmigrate/compatibility_test.go @@ -68,7 +68,7 @@ func TestCompatibilityAssetsCompileAgainstReleasedPkg(t *testing.T) { go ` + currentTargetGoVersion + ` require ( - github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/grpc-kit/pkg ` + pkgVersion + ` github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 diff --git a/internal/projectmigrate/diagnostics_test.go b/internal/projectmigrate/diagnostics_test.go index 3e7a1ca..ee54b53 100644 --- a/internal/projectmigrate/diagnostics_test.go +++ b/internal/projectmigrate/diagnostics_test.go @@ -55,7 +55,7 @@ var managed *logrus.Entry if err != nil { t.Fatal(err) } - actions, err := ScanManualActions(project, map[string]struct{}{"managed.go": {}}) + actions, err := ScanManualActions(project, map[string]struct{}{"managed.go": {}}, "0.4.0") if err != nil { t.Fatal(err) } diff --git a/internal/projectmigrate/plan_test.go b/internal/projectmigrate/plan_test.go index 6fe0cd5..0097795 100644 --- a/internal/projectmigrate/plan_test.go +++ b/internal/projectmigrate/plan_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -90,6 +91,162 @@ func TestBuildPlanDoesNotCreateMissingAssets(t *testing.T) { } } +func TestBuildPlanRewritesLegacyGenerateScript(t *testing.T) { + root := writePlanProject(t, false) + writeFile(t, filepath.Join(root, "scripts", "generate.sh"), legacyGenerateScriptFixture, 0o755) + + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusReady || plan.Blocked() { + t.Fatalf("plan status = %s, conflicts = %#v", plan.Status, plan.Conflicts) + } + wantChanges := []string{"scripts/env", "scripts/generate.sh"} + if got := changePaths(plan.Changes); !slices.Equal(got, wantChanges) { + t.Fatalf("change paths = %v, want %v", got, wantChanges) + } + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + for _, change := range plan.Changes { + if change.Path != generateScriptPath { + continue + } + if !bytes.Equal(change.After, target) { + t.Fatal("generate.sh rewrite does not equal the rendered target") + } + marker, err := ParseMarkerLine(secondLine(change.After)) + if err != nil || marker.Version != "0.4.0" || marker.CommentPrefix != "#" { + t.Fatalf("generate.sh rewrite marker = %#v, want script marker 0.4.0", marker) + } + } + if hasDiagnosticCode(plan.ManualActions, "generate_script_modified") { + t.Fatalf("planned rewrite also produced a manual action: %#v", plan.ManualActions) + } +} + +func TestBuildPlanSkipsCurrentGenerateScript(t *testing.T) { + root := writePlanProject(t, false) + scriptPath := filepath.Join(root, "scripts", "generate.sh") + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + writeFile(t, scriptPath, target, 0o755) + + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if got := changePaths(plan.Changes); !slices.Equal(got, []string{"scripts/env"}) { + t.Fatalf("change paths = %v, want only scripts/env", got) + } + if hasDiagnosticCode(plan.ManualActions, "generate_script_modified") { + t.Fatalf("current content produced a manual action: %#v", plan.ManualActions) + } + body, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(body, target) { + t.Fatal("preview modified an up-to-date generate.sh") + } +} + +func TestBuildPlanLeavesModifiedMarkedGenerateScriptToManualAction(t *testing.T) { + root := writePlanProject(t, false) + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "scripts", "generate.sh"), append(bytes.Clone(target), []byte("# local edit\n")...), 0o755) + + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusReady || plan.Blocked() { + t.Fatalf("modified marked generate.sh must not block the plan: status = %s, conflicts = %#v", plan.Status, plan.Conflicts) + } + if got := changePaths(plan.Changes); !slices.Equal(got, []string{"scripts/env"}) { + t.Fatalf("change paths = %v, want only scripts/env", got) + } + if !hasDiagnostic(plan.ManualActions, "generate_script_modified", "scripts/generate.sh") { + t.Fatalf("manual actions = %#v, want generate_script_modified", plan.ManualActions) + } +} + +func TestBuildPlanLeavesModifiedGenerateScriptToManualAction(t *testing.T) { + root := writePlanProject(t, false) + writeFile(t, filepath.Join(root, "scripts", "generate.sh"), append(bytes.Clone(legacyGenerateScriptFixture), []byte("# local edit\n")...), 0o755) + + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusReady || plan.Blocked() { + t.Fatalf("modified generate.sh must not block the plan: status = %s, conflicts = %#v", plan.Status, plan.Conflicts) + } + if got := changePaths(plan.Changes); !slices.Equal(got, []string{"scripts/env"}) { + t.Fatalf("change paths = %v, want only scripts/env", got) + } + if !hasDiagnostic(plan.ManualActions, "generate_script_modified", "scripts/generate.sh") { + t.Fatalf("manual actions = %#v, want generate_script_modified", plan.ManualActions) + } +} + +func TestBuildPlanIgnoresMissingGenerateScript(t *testing.T) { + root := writePlanProject(t, false) + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if got := changePaths(plan.Changes); !slices.Equal(got, []string{"scripts/env"}) { + t.Fatalf("change paths = %v, want only scripts/env", got) + } + if hasDiagnosticCode(plan.ManualActions, "generate_script_modified") { + t.Fatalf("missing file produced a manual action: %#v", plan.ManualActions) + } +} + +func TestBuildPlanConflictsWhenGenerateScriptPathIsUnsafe(t *testing.T) { + t.Run("directory", func(t *testing.T) { + root := writePlanProject(t, false) + if err := os.Mkdir(filepath.Join(root, "scripts", "generate.sh"), 0o755); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusConflict || !hasDiagnostic(plan.Conflicts, "unsafe_file", "scripts/generate.sh") { + t.Fatalf("plan = %#v, want unsafe_file conflict", plan) + } + }) + t.Run("symlink", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior requires elevated privileges on some Windows hosts") + } + root := writePlanProject(t, false) + target := filepath.Join(root, "actual-generate.sh") + if err := os.WriteFile(target, legacyGenerateScriptFixture, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "scripts", "generate.sh")); err != nil { + t.Fatal(err) + } + plan, err := BuildPlan(root, "0.4.0") + if err != nil { + t.Fatal(err) + } + if plan.Status != StatusConflict || !hasDiagnostic(plan.Conflicts, "unsafe_file", "scripts/generate.sh") { + t.Fatalf("plan = %#v, want unsafe_file conflict", plan) + } + }) +} + func TestWritePlanIncludesUnifiedDiff(t *testing.T) { root := writePlanProject(t, false) plan, err := BuildPlan(root, "0.4.0") diff --git a/internal/projectmigrate/scriptpatch_test.go b/internal/projectmigrate/scriptpatch_test.go new file mode 100644 index 0000000..69265c4 --- /dev/null +++ b/internal/projectmigrate/scriptpatch_test.go @@ -0,0 +1,224 @@ +package projectmigrate + +import ( + "bytes" + "crypto/sha256" + "fmt" + "os" + "testing" + "text/template" + + _ "embed" +) + +//go:embed testdata/legacy-v0.3.8-generate.sh +var legacyGenerateScriptFixture []byte + +func TestLegacyGenerateScriptFixtureDigest(t *testing.T) { + digest := fmt.Sprintf("%x", sha256.Sum256(legacyGenerateScriptFixture)) + if digest != legacyGenerateScriptV038SHA256 { + t.Fatalf("legacy fixture digest = %s, want %s", digest, legacyGenerateScriptV038SHA256) + } + if !bytes.HasPrefix(legacyGenerateScriptFixture, []byte("#!/bin/bash\n")) { + t.Fatal("legacy fixture must keep the shebang on line 1") + } + if bytes.Contains(legacyGenerateScriptFixture, []byte(`-I "${GOPATH}"/include/`)) { + t.Fatal("legacy fixture must predate the ${GOPATH}/include include path") + } + if !bytes.Contains(legacyGenerateScriptFixture, []byte(`-I /usr/local/include/`)) { + t.Fatal("legacy fixture must rely on /usr/local/include") + } +} + +func TestGenerateScriptAsset(t *testing.T) { + if !bytes.HasPrefix(generateScriptAsset, []byte("#!/bin/bash\n")) { + t.Fatal("frozen asset must keep the shebang on line 1") + } + if markerLine := secondLine(generateScriptAsset); markerLine != `# Code generated by "grpc-kit-cli/{{ .TargetCLIVersion }}". DO NOT EDIT.` { + t.Fatalf("frozen asset line 2 = %q, want the version marker expression", markerLine) + } + digest := fmt.Sprintf("%x", sha256.Sum256(generateScriptAsset)) + if digest != generateScriptAssetSHA256 { + t.Fatalf("frozen asset digest = %s, want %s", digest, generateScriptAssetSHA256) + } +} + +func TestRenderGenerateScriptTarget(t *testing.T) { + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(target, []byte("#!/bin/bash\n")) { + t.Fatal("rendered target must keep the shebang on line 1") + } + marker, err := ParseMarkerLine(secondLine(target)) + if err != nil { + t.Fatalf("rendered target line 2 does not parse as a script marker: %v", err) + } + if marker.Version != "0.4.0" || marker.CommentPrefix != "#" { + t.Fatalf("rendered target marker = %#v, want version 0.4.0 with # prefix", marker) + } + for _, include := range []string{`-I "${GOPATH}"/include/ \`, `-I /usr/local/include/ \`} { + if count := bytes.Count(target, []byte(include)); count != 2 { + t.Fatalf("include %q appears %d times, want 2 (both protoc calls)", include, count) + } + } + if bytes.Contains(target, []byte("{{")) || bytes.Contains(target, []byte("}}")) { + t.Fatal("rendered target must not retain template expressions") + } + if _, err := renderGenerateScriptTarget("not-a-version"); err == nil { + t.Fatal("renderGenerateScriptTarget() accepted an invalid version") + } +} + +func TestGenerateScriptAssetMatchesServiceTemplate(t *testing.T) { + // Conscious-sync guard: changing template/service/scripts/generate.sh.tmpl + // requires re-freezing the embedded asset and generateScriptAssetSHA256. + templateBody, err := os.ReadFile("../../template/service/scripts/generate.sh.tmpl") + if err != nil { + t.Fatal(err) + } + tmpl, err := template.New("service-template").Option("missingkey=error").Parse(string(templateBody)) + if err != nil { + t.Fatal(err) + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, map[string]any{ + "Global": map[string]any{"ReleaseVersion": "0.4.0"}, + }); err != nil { + t.Fatal(err) + } + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(rendered.Bytes(), target) { + t.Fatal("embedded asset drifted from template/service/scripts/generate.sh.tmpl; re-freeze the asset and digest") + } +} + +func TestPlanGenerateScriptStates(t *testing.T) { + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + other, err := renderGenerateScriptTarget("0.5.1") + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + body []byte + mode os.FileMode + target []byte + wantUpTo bool + wantPath string + }{ + {name: "legacy content rewrites", body: legacyGenerateScriptFixture, mode: 0o755, target: target, wantPath: generateScriptPath}, + {name: "current content is up to date", body: target, mode: 0o640, target: target, wantUpTo: true}, + {name: "other marker version defers to manual action", body: other, mode: 0o755, target: target}, + {name: "unknown content defers to manual action", body: append(bytes.Clone(legacyGenerateScriptFixture), []byte("# local edit\n")...), mode: 0o755, target: target}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + change, upToDate := planGenerateScript(test.body, test.mode, test.target) + if upToDate != test.wantUpTo { + t.Fatalf("planGenerateScript() upToDate = %v, want %v", upToDate, test.wantUpTo) + } + if test.wantPath == "" { + if change != nil { + t.Fatalf("planGenerateScript() change = %#v, want nil", change) + } + return + } + if change == nil { + t.Fatal("planGenerateScript() change = nil, want a rewrite") + } + if change.Path != test.wantPath { + t.Fatalf("change.Path = %q, want %q", change.Path, test.wantPath) + } + if change.Mode != test.mode { + t.Fatalf("change.Mode = %v, want %v", change.Mode, test.mode) + } + if !bytes.Equal(change.Before, test.body) { + t.Fatal("change.Before does not preserve the on-disk content") + } + if !bytes.Equal(change.After, test.target) { + t.Fatal("change.After does not equal the rendered target") + } + if digest := fmt.Sprintf("%x", change.BeforeSHA256); digest != fmt.Sprintf("%x", sha256.Sum256(test.body)) { + t.Fatal("change.BeforeSHA256 does not match the on-disk content") + } + }) + } +} + +func TestScanGenerateScriptAction(t *testing.T) { + root := writeProject(t, validEnvironment, "module git.lmq.io/kaopuvm/spider\n") + project, err := LoadProject(root) + if err != nil { + t.Fatal(err) + } + scriptPath := root + "/scripts/generate.sh" + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + + collect := func(changedPaths map[string]struct{}) []Diagnostic { + t.Helper() + var actions []Diagnostic + dedup := make(map[string]struct{}) + if err := scanGenerateScriptAction(project, changedPaths, &actions, dedup, "0.4.0"); err != nil { + t.Fatal(err) + } + return actions + } + + if actions := collect(nil); len(actions) != 0 { + t.Fatalf("absent file produced actions: %#v", actions) + } + + if err := os.WriteFile(scriptPath, legacyGenerateScriptFixture, 0o755); err != nil { + t.Fatal(err) + } + if actions := collect(nil); len(actions) != 0 { + t.Fatalf("legacy content produced actions: %#v", actions) + } + if err := os.WriteFile(scriptPath, target, 0o755); err != nil { + t.Fatal(err) + } + if actions := collect(nil); len(actions) != 0 { + t.Fatalf("current content produced actions: %#v", actions) + } + + if err := os.WriteFile(scriptPath, append(bytes.Clone(legacyGenerateScriptFixture), []byte("# local edit\n")...), 0o755); err != nil { + t.Fatal(err) + } + if actions := collect(nil); !hasDiagnosticCode(actions, "generate_script_modified") { + t.Fatalf("modified content produced no manual action: %#v", actions) + } + if err := os.WriteFile(scriptPath, append(bytes.Clone(target), []byte("# local edit\n")...), 0o755); err != nil { + t.Fatal(err) + } + if actions := collect(nil); !hasDiagnosticCode(actions, "generate_script_modified") { + t.Fatalf("modified marker-carrying content produced no manual action: %#v", actions) + } + if actions := collect(map[string]struct{}{generateScriptPath: {}}); len(actions) != 0 { + t.Fatalf("planned rewrite still produced a manual action: %#v", actions) + } +} + +func TestPlanGenerateScriptDoesNotMutateTarget(t *testing.T) { + target, err := renderGenerateScriptTarget("0.4.0") + if err != nil { + t.Fatal(err) + } + before := bytes.Clone(target) + if _, upToDate := planGenerateScript(legacyGenerateScriptFixture, 0o755, target); upToDate { + t.Fatal("legacy content reported up to date") + } + if !bytes.Equal(target, before) { + t.Fatal("planGenerateScript mutated the rendered target") + } +} diff --git a/template/service_test.go b/template/service_test.go index 624d0fa..186fd1a 100644 --- a/template/service_test.go +++ b/template/service_test.go @@ -81,6 +81,7 @@ func TestServiceTemplateRendersExtensions(t *testing.T) { } assertContains( "scripts/generate.sh", + "# Code generated by \"grpc-kit-cli/0.0.1-test\". DO NOT EDIT.", "set -e", "--grpc-gateway_opt grpc_api_configuration=", "--openapiv2_opt grpc_api_configuration=", @@ -125,7 +126,8 @@ func TestServiceTemplateRendersExtensions(t *testing.T) { assertContains("modeler/mcp/registrar.go", "func (r *Registrar) Register", "server is nil") assertContains("modeler/mcp/registrar_test.go", "session.CallTool", "session.ReadResource", "session.GetPrompt") assertContains("config/app-dev-local.yaml", "aiconnector:", "mcp_server:") - assertContains("go.mod", "go 1.25.13", "github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0", "github.com/grpc-kit/pkg v0.5.0", "github.com/modelcontextprotocol/go-sdk v1.7.0", "go.opentelemetry.io/otel v1.45.0", "google.golang.org/grpc v1.83.1") + assertContains("go.mod", "go 1.25.13", "github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0", "github.com/grpc-kit/pkg v0.5.0", "github.com/modelcontextprotocol/go-sdk v1.7.0", "go.opentelemetry.io/otel v1.44.0", "google.golang.org/grpc v1.83.1") + assertContains("scripts/binaries.sh", `GRPC_GATEWAY_VERSION="v2.30.0"`) assertNotContains("go.mod", "github.com/sirupsen/logrus") assertContains("Makefile", ">> synchronize Go module dependencies", "@${GO} mod tidy") assertContains("AGENTS.md", "## Shared Skills", "scripts/skills/skills/generate-release-changelog/SKILL.md") From ec0c10ca10b991a4cd705720ad939543ac5823cf Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 20:17:45 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=E4=BF=AE=E8=AE=A2=200.5=20?= =?UTF-8?q?=E6=9C=AA=E5=8F=91=E5=B8=83=E6=97=A5=E5=BF=97=EF=BC=9Agrpc-gate?= =?UTF-8?q?way=20runtime=20=E5=9B=A0=20otelgrpc=20v0.70.0=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20StreamClientInterceptor=20=E6=9A=82=E7=95=99=20v2.2?= =?UTF-8?q?9.0=20=E5=BE=85=20pkg=20=E5=85=BC=E5=AE=B9=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20project=20migrate=20=E6=89=98=E7=AE=A1=20generate.s?= =?UTF-8?q?h=20=E7=9A=84=20marker=20=E4=B8=8E=E5=86=BB=E7=BB=93=E6=91=98?= =?UTF-8?q?=E8=A6=81=E8=A7=84=E5=88=99=E5=8F=8A=20generate.sh.tmpl=20?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E4=B8=AA=20protoc=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=8F=8C=20include=20=E8=B7=AF=E5=BE=84=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG/CHANGELOG-0.5.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG/CHANGELOG-0.5.md b/CHANGELOG/CHANGELOG-0.5.md index 2ca14a9..a3768e4 100644 --- a/CHANGELOG/CHANGELOG-0.5.md +++ b/CHANGELOG/CHANGELOG-0.5.md @@ -13,13 +13,16 @@ ### Changed -- 将 CLI 与新服务模板使用的 `protoc-gen-grpc-gateway`、`protoc-gen-openapiv2`、grpc-gateway runtime 和 Docker include 源统一升级到 `v2.30.0`;同步将模板直接使用的 OpenTelemetry 模块提升到 `v1.45.0`,满足新版本依赖下限。 +- 将 CLI 与新服务模板使用的 `protoc-gen-grpc-gateway`、`protoc-gen-openapiv2` 和 Docker include 源升级到 `v2.30.0`。生成项目的 grpc-gateway runtime 暂时保留 `v2.29.0`:v2.30.0 要求的 `otelgrpc v0.70.0` 删除了已发布 `grpc-kit/pkg v0.5.0` 仍使用的 `StreamClientInterceptor`,待 pkg 发布兼容版本后再同步升级 runtime。 +- `project migrate` 将存量项目的 `scripts/generate.sh` 纳入托管同步:服务模板自本版本起在该文件第二行携带 `# Code generated by "grpc-kit-cli/...". DO NOT EDIT.` 生成标记(首行保留 shebang)。存量副本没有该标记,迁移以"路径精确 + 全文 SHA-256 匹配冻结历史版本"的内容证据窄例外授权(与 `public/embed.go` 既有先例同规则),命中 v0.3.8 冻结摘要的副本被整文件改写为最新模板并补上标记;迁移产物与 0.5.1 起新脚手架的 `generate.sh` 从此携带 marker,后续版本可改用 marker 规则托管。本地修改过或内容不明的副本只输出 `generate_script_modified` 人工核对提示,不自动覆盖;文件缺失不创建。 ### Fixed +- 修复 d454cd6 工具链固化迁移遗漏:`template/service/scripts/generate.sh.tmpl` 第二个 `protoc` 调用仍单独引用旧安装位置 `/usr/local/include`,在新工具链(`binaries.sh` 安装到 `${GOPATH}/include`)下第二个 `protoc` 调用找不到 well-known types。两个 `protoc` 调用现在统一按 `-I "${GOPATH}"/include/`(优先)+ `-I /usr/local/include/`(旧安装位置兜底)的顺序包含双路径,新旧工具链及存量外部 CI 工作流均无需改动;`generate.sh` 模板同时在第二行新增生成标记。 + - 修复服务模板在 `protoc-gen-openapiv2 v2.30.0` 下生成的 4xx/5xx 公共 `ErrorResponse` `$ref` 无法解析的问题:生成脚本启用 `allow_merge`,显式输入 `${GOPATH}/src/github.com/grpc-kit/api/known/status/v1/response.proto`,并将合并后的 `microservice.swagger.json` 直接输出到 `public/openapi/`;新增 `$ref` 完整性与关键公共 schema 回归门禁。 -- 该问题的已知版本边界为 grpc-gateway `v2.27.5` 起;`v2.27.4` 仅用于说明问题边界,不作为实施或应急回滚方案,模板升级并固定使用当前 `v2.30.0` 工具链。 -- HTTP 路由继续独立维护在 `microservice.gateway.yaml`,运行时错误响应契约和 gateway 代码不变。官方 `protoc-gen-openapiv3` 当前不读取该外部配置,因此 OpenAPI v3 条件生成延后,不随本次修复发布。 +- 该问题的已知版本边界为 grpc-gateway `v2.27.5` 起;`v2.27.4` 仅用于说明问题边界,不作为实施或应急回滚方案,模板生成工具升级并固定使用当前 `v2.30.0`。 +- HTTP 路由继续独立维护在 `microservice.gateway.yaml`,运行时错误响应契约不变;v2.30.0 重新生成的 gateway 代码仅包含上游请求体清理顺序调整,并已由生成项目与 gochat 测试覆盖。官方 `protoc-gen-openapiv3` 当前不读取该外部配置,因此 OpenAPI v3 条件生成延后,不随本次修复发布。 ## [0.5.0] - 2026-09-16 From eb02002f1eb6ded81b7e5e59c201c3ec9f4d1349 Mon Sep 17 00:00:00 2001 From: mingqing Date: Thu, 17 Sep 2026 23:25:12 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=E5=8D=87=E7=BA=A7=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E7=89=88=E6=9C=AC=E8=87=B3=20v0.5.1=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=B5=8B=E8=AF=95=E4=B8=8E=E6=A8=A1=E6=9D=BF=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 7 ++++--- CHANGELOG/CHANGELOG-0.5.md | 12 ++++++++++-- VERSION | 2 +- template/compatibility_test.go | 4 ++-- template/service/go.mod.tmpl | 12 ++++++------ template/service_test.go | 2 +- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5e50dd..3f2616d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: go-version: 1.25.13 cache-dependency-path: go.sum - run: make test-compatibility PKG_COMPAT_VERSION=v0.5.0 + - run: make test-compatibility PKG_COMPAT_VERSION=v0.5.1 template-compatibility: runs-on: ubuntu-latest @@ -44,8 +45,8 @@ jobs: make protoc go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 - go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.29.0 - go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.29.0 + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.30.0 + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.30.0 - name: Install protobuf include sources run: | mkdir -p "$(go env GOPATH)/src/github.com/grpc-kit" @@ -56,5 +57,5 @@ jobs: git -C "$(go env GOPATH)/src/github.com/googleapis/googleapis" remote add origin https://github.com/googleapis/googleapis.git git -C "$(go env GOPATH)/src/github.com/googleapis/googleapis" fetch --depth 1 origin e0d0106516a5c613510533821e4508bc6c943b11 git -C "$(go env GOPATH)/src/github.com/googleapis/googleapis" checkout --detach FETCH_HEAD - git clone --branch v2.29.0 --depth 1 https://github.com/grpc-ecosystem/grpc-gateway.git "$(go env GOPATH)/src/github.com/grpc-ecosystem/grpc-gateway" + git clone --branch v2.30.0 --depth 1 https://github.com/grpc-ecosystem/grpc-gateway.git "$(go env GOPATH)/src/github.com/grpc-ecosystem/grpc-gateway" - run: make test-template-compatibility diff --git a/CHANGELOG/CHANGELOG-0.5.md b/CHANGELOG/CHANGELOG-0.5.md index a3768e4..804b82b 100644 --- a/CHANGELOG/CHANGELOG-0.5.md +++ b/CHANGELOG/CHANGELOG-0.5.md @@ -9,11 +9,19 @@ | Fixed | 功能的修复 | | Security | 有关安全问题的修复 | -## [Unreleased] +## [0.5.1] - 2026-09-17 ### Changed -- 将 CLI 与新服务模板使用的 `protoc-gen-grpc-gateway`、`protoc-gen-openapiv2` 和 Docker include 源升级到 `v2.30.0`。生成项目的 grpc-gateway runtime 暂时保留 `v2.29.0`:v2.30.0 要求的 `otelgrpc v0.70.0` 删除了已发布 `grpc-kit/pkg v0.5.0` 仍使用的 `StreamClientInterceptor`,待 pkg 发布兼容版本后再同步升级 runtime。 +- 将 CLI 与新服务模板使用的 `protoc-gen-grpc-gateway`、`protoc-gen-openapiv2` 和 Docker include 源升级到 `v2.30.0`,生成项目的 grpc-gateway runtime 依赖同步升级到 `v2.30.0` 与 `grpc-kit/pkg v0.5.1`(含 OTel `v1.45.0`、gRPC `v1.83.2`):v2.30.0 要求的 `otelgrpc v0.70.0` 删除了 `pkg v0.5.0` 仍使用的 `StreamClientInterceptor`,runtime 升级以 `pkg v0.5.1` 完成向 stats handler 的迁移为前提;CI 模板门禁的生成工具安装同步对齐 `v2.30.0`。 +- `grpc-kit/pkg v0.5.1`:客户端 OpenTelemetry 接入从已被上游移除的 `otelgrpc.StreamClientInterceptor()` 迁移到 `grpc.WithStatsHandler(otelgrpc.NewClientHandler(...))`,装配点统一收敛到 `LocalConfig.GetClientDialOption()`;依赖对齐 `grpc-gateway v2.30.0`、`otelgrpc/otelhttp v0.70.0` 与 OpenTelemetry Go `v1.45.0`,并升级 `google.golang.org/grpc v1.83.2` 修复 GO-2026-6443 server panic(govulncheck 符号级命中 `rpc.StartBackground` 调用链)。 + + 1. 客户端遥测覆盖面扩大:普通客户端 unary RPC 与 grpc-gateway 到 gRPC Server 的内部调用新增 OTel client span/metric,streaming client 改由同一 stats handler 观测;unary/stream 共用 `grpcTracingEnableFilter`,`HealthCheck` 与配置命中的方法在 client 侧同样被过滤;observability 关闭时不装配框架 OTel client handler + 2. 调用方自定义 stats handler 与框架 handler 可共存(grpc v1.83.x 的 `WithStatsHandler` 为追加语义),应避免再注册另一个 `otelgrpc.NewClientHandler()` 产生重复 span/metric + 3. `GetClientStreamInterceptor()` 保留为通用流拦截器扩展入口且未废弃,但默认不再返回 OTel interceptor;只调用该方法、不使用 `GetClientDialOption()` 的外部项目在 `otelgrpc v0.70.0` 依赖图下无法编译,需改用 `GetClientDialOption()` + 4. OTel 依赖族对齐到 v1.45.0/v0.70.0 一代:`contrib/instrumentation/runtime v0.70.0`、otlp exporters(5 个)`v1.45.0`、`stdoutmetric v1.45.0`、`exporters/prometheus v0.67.0`(连带 `prometheus/client_golang v1.24.1`);`go` 指令保持 `1.25.13` + 5. govulncheck 符号级漏洞清零;未调用级漏洞随间接依赖升级修复 `x/mod v0.40.0`(GO-2026-6180/6179)、`oras-go v2.6.2`(GO-2026-5880)、`go-ntlmssp v0.1.1`(GO-2026-5543),`x/crypto`(GO-2026-6355/6354,修复版要求 go 1.26)、`etcd/client/pkg`(GO-2026-6107,修复需 v3.5.33)与上游暂无修复版的 GO-2026-5932 留待后续版本处理 +- `project migrate` 的迁移目标基线保持 `pkg v0.5.0` 不变:`v0.5.1` 被定义为 `v0.5.0` 的补丁兼容候选,CI 迁移门禁在 `v0.5.0` 基线之外增加 `v0.5.1` 候选验证,不改写历史迁移夹具与冻结兼容资产。 - `project migrate` 将存量项目的 `scripts/generate.sh` 纳入托管同步:服务模板自本版本起在该文件第二行携带 `# Code generated by "grpc-kit-cli/...". DO NOT EDIT.` 生成标记(首行保留 shebang)。存量副本没有该标记,迁移以"路径精确 + 全文 SHA-256 匹配冻结历史版本"的内容证据窄例外授权(与 `public/embed.go` 既有先例同规则),命中 v0.3.8 冻结摘要的副本被整文件改写为最新模板并补上标记;迁移产物与 0.5.1 起新脚手架的 `generate.sh` 从此携带 marker,后续版本可改用 marker 规则托管。本地修改过或内容不明的副本只输出 `generate_script_modified` 人工核对提示,不自动覆盖;文件缺失不创建。 ### Fixed diff --git a/VERSION b/VERSION index 8f0916f..5d4294b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1 \ No newline at end of file diff --git a/template/compatibility_test.go b/template/compatibility_test.go index f130f55..4aeabf5 100644 --- a/template/compatibility_test.go +++ b/template/compatibility_test.go @@ -72,8 +72,8 @@ func TestGeneratedServiceCompilesAgainstReleasedPkg(t *testing.T) { pkgVersion = requirement.Mod.Version } } - if pkgVersion != "v0.5.0" { - t.Fatalf("generated pkg version = %q, want v0.5.0", pkgVersion) + if pkgVersion != "v0.5.1" { + t.Fatalf("generated pkg version = %q, want v0.5.1", pkgVersion) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) diff --git a/template/service/go.mod.tmpl b/template/service/go.mod.tmpl index b1acdbd..2eb716d 100644 --- a/template/service/go.mod.tmpl +++ b/template/service/go.mod.tmpl @@ -4,15 +4,15 @@ go 1.25.13 require ( entgo.io/ent v0.14.5 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 - github.com/grpc-kit/pkg v0.5.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/grpc-kit/pkg v0.5.1 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.83.1 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 k8s.io/client-go v0.34.1 ) diff --git a/template/service_test.go b/template/service_test.go index 186fd1a..1a39462 100644 --- a/template/service_test.go +++ b/template/service_test.go @@ -126,7 +126,7 @@ func TestServiceTemplateRendersExtensions(t *testing.T) { assertContains("modeler/mcp/registrar.go", "func (r *Registrar) Register", "server is nil") assertContains("modeler/mcp/registrar_test.go", "session.CallTool", "session.ReadResource", "session.GetPrompt") assertContains("config/app-dev-local.yaml", "aiconnector:", "mcp_server:") - assertContains("go.mod", "go 1.25.13", "github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0", "github.com/grpc-kit/pkg v0.5.0", "github.com/modelcontextprotocol/go-sdk v1.7.0", "go.opentelemetry.io/otel v1.44.0", "google.golang.org/grpc v1.83.1") + assertContains("go.mod", "go 1.25.13", "github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0", "github.com/grpc-kit/pkg v0.5.1", "github.com/modelcontextprotocol/go-sdk v1.7.0", "go.opentelemetry.io/otel v1.45.0", "google.golang.org/grpc v1.83.2") assertContains("scripts/binaries.sh", `GRPC_GATEWAY_VERSION="v2.30.0"`) assertNotContains("go.mod", "github.com/sirupsen/logrus") assertContains("Makefile", ">> synchronize Go module dependencies", "@${GO} mod tidy")