diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..85d0bb59 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + target-branch: master + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + target-branch: master + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: ci diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..e731066e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: '30 1 * * 1' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (Go) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@a2983b8bed1923f44751c5c43237f479442827b3 # v3 + with: + languages: go + build-mode: autobuild + + - name: Analyze + uses: github/codeql-action/analyze@a2983b8bed1923f44751c5c43237f479442827b3 # v3 diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 8e53cb22..6e0765ca 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -2,66 +2,141 @@ name: CI on: push: - branches: [ master ] + branches: + - master pull_request: - branches: "*" + branches: + - master + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: license: name: Check License Header runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false - name: Check License Header - uses: apache/skywalking-eyes/header@main #NOSONAR - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: apache/skywalking-eyes/header@315732dd4b8d3a015d8d9b91936b935a0b854817 # verified main, post-v0.8.0 with: config: .licenserc.yaml mode: check - CI: - name: CI + test-and-lint: + name: Test and Lint + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify + + - name: Check Code Format + run: make check-fmt + + - name: Unit Test + run: make test + + - name: Lint + run: make lint + + - name: Upload Coverage Artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: coverage + path: coverage.txt + if-no-files-found: error + retention-days: 1 + + coverage: + name: Upload Coverage + needs: test-and-lint + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + - name: Download Coverage Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: coverage + + - name: Upload Coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f + with: + version: v11.3.1 + use_oidc: true + fail_ci_if_error: true + files: ./coverage.txt + disable_search: true + + race: + name: Race + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify + + - name: Race Test + run: make test-race + + build: + name: Build (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: - # If you want to matrix build , you can append the following list. + fail-fast: false matrix: - go_version: - - '1.25' os: - ubuntu-latest - + - windows-latest + - macos-latest steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify - - name: Set up Go ${{ matrix.go_version }} - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go_version }} - - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - - name: Cache Go Dependence - # ref: https://github.com/actions/cache/blob/main/examples.md#go---module - uses: actions/cache@v4 - with: - # Cache, works only on Linux - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - # An ordered list of keys to use for restoring the cache if no cache hit occurred for key - restore-keys: ${{ runner.os }}-go- - - - name: Check Code Format - run: make fmt && git status && [[ -z `git status -s` ]] - - - name: Unit Test - run: make test - - - name: Lint - run: make lint - - - name: Coverage - run: bash <(curl -s https://codecov.io/bash) + - name: Build + run: go build ./... diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 362c1969..00000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You 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. - -language: go - -os: - - linux - -go: - - "1.13" - -env: - - GO111MODULE=on - -install: true -script: - - echo 'start license check' - - sh before_validate_license.sh - - chmod u+x /tmp/tools/license/license-header-checker - - /tmp/tools/license/license-header-checker -v -a -r -i vendor /tmp/tools/license/license.txt . go && [[ -z `git status -s` ]] - - go fmt ./... && [[ -z `git status -s` ]] - - go mod vendor && go test $(go list ./... | grep -v vendor | grep -v examples) -coverprofile=coverage.txt -covermode=atomic - -after_success: - - bash <(curl -s https://codecov.io/bash) -t "26520766-2aa8-4b82-8e44-f778d718b4d9" - -notifications: - webhooks: https://oapi.dingtalk.com/robot/send?access_token=75f4f1ec3868508aa89e5a5d6f9d342216809df3ebc8a78c8ae8722848e06166 - webhooks: https://oapi.dingtalk.com/robot/send?access_token=072b74afbf3e746adeac1edecd5823cd24625a97eac42862476046e3057fb5ab \ No newline at end of file diff --git a/Makefile b/Makefile index e56de5c9..a0f24c5e 100644 --- a/Makefile +++ b/Makefile @@ -21,24 +21,54 @@ MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules MAKEFLAGS += --no-print-directory -.PHONY: help test fmt clean lint +.PHONY: help test test-race fmt check-fmt clean lint install-golangci-lint install-imports-formatter help: @echo "Available commands:" - @echo " test - Run unit tests" - @echo " clean - Clean test generate files" + @echo " test - Run unit tests with coverage" + @echo " test-race - Run transport race tests" @echo " fmt - Format code" + @echo " check-fmt - Verify formatting without modifying tracked files" @echo " lint - Run golangci-lint" + @echo " clean - Clean test generate files" # Run unit tests test: clean - # For go 1.25.0 - go env -w GOTOOLCHAIN=go1.25.0+auto - go test ./... -coverprofile=coverage.txt -covermode=atomic + GOTOOLCHAIN=go1.25.0+auto go test ./... -count=1 -coverprofile=coverage.txt -covermode=atomic + +test-race: + GOTOOLCHAIN=go1.25.0+auto go test -race ./transport -count=1 fmt: install-imports-formatter go fmt ./... && GOROOT=$(shell go env GOROOT) imports-formatter +check-fmt: install-imports-formatter + @temp_dir=$$(mktemp -d /tmp/getty-check-fmt.XXXXXX); \ + trap 'case "$$temp_dir" in /tmp/getty-check-fmt.*) rm -rf -- "$$temp_dir" ;; esac' EXIT; \ + mkdir -p "$$temp_dir/.git"; \ + tracked_files="$$temp_dir/.git/tracked-files.z"; \ + go_files="$$temp_dir/.git/go-files.z"; \ + git ls-files -z > "$$tracked_files"; \ + git ls-files -z -- '*.go' > "$$go_files"; \ + while IFS= read -r -d '' file; do \ + mkdir -p "$$temp_dir/$$(dirname "$$file")"; \ + cp -p -- "$$file" "$$temp_dir/$$file"; \ + done < "$$tracked_files"; \ + (cd "$$temp_dir" && \ + GOTOOLCHAIN=go1.25.0+auto go fmt ./... && \ + GOROOT="$$(GOTOOLCHAIN=go1.25.0+auto go env GOROOT)" \ + imports-formatter --path "$$temp_dir" --module github.com/AlexStocks/getty); \ + status=0; \ + while IFS= read -r -d '' file; do \ + current_hash=$$(git hash-object --path="$$file" "$$file"); \ + formatted_hash=$$(git hash-object --path="$$file" "$$temp_dir/$$file"); \ + if test "$$current_hash" != "$$formatted_hash"; then \ + printf 'Formatting changes are required: %s\n' "$$file"; \ + status=1; \ + fi; \ + done < "$$go_files"; \ + exit "$$status" + # Clean test generate files clean: rm -rf coverage.txt @@ -52,4 +82,4 @@ install-golangci-lint: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 install-imports-formatter: - go install github.com/dubbogo/tools/cmd/imports-formatter@latest + go install github.com/dubbogo/tools/cmd/imports-formatter@v1.0.10 diff --git a/README.md b/README.md index 265833a1..55c37558 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ *a netty like asynchronous network I/O library* -[![Build Status](https://travis-ci.org/AlexStocks/getty.svg?branch=master)](https://travis-ci.org/AlexStocks/getty) +[![CI](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml/badge.svg?branch=master)](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml) [![codecov](https://codecov.io/gh/AlexStocks/getty/branch/master/graph/badge.svg)](https://codecov.io/gh/AlexStocks/getty) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/AlexStocks/getty?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/AlexStocks/getty)](https://goreportcard.com/report/github.com/AlexStocks/getty) diff --git a/README_CN.md b/README_CN.md index 2732a767..b2b6f366 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ *一个类似 Netty 的异步网络 I/O 库* -[![Build Status](https://travis-ci.org/AlexStocks/getty.svg?branch=master)](https://travis-ci.org/AlexStocks/getty) +[![CI](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml/badge.svg?branch=master)](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml) [![codecov](https://codecov.io/gh/AlexStocks/getty/branch/master/graph/badge.svg)](https://codecov.io/gh/AlexStocks/getty) [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/AlexStocks/getty?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/AlexStocks/getty)](https://goreportcard.com/report/github.com/AlexStocks/getty) @@ -554,4 +554,4 @@ session.AddCloseCallback([]int{1, 2, 3}, "key", callback) // 记录日 ## 许可证 -Apache 许可证 2.0 \ No newline at end of file +Apache 许可证 2.0 diff --git a/doc/superpowers/plans/2026-08-01-github-ci-hardening.md b/doc/superpowers/plans/2026-08-01-github-ci-hardening.md new file mode 100644 index 00000000..10758924 --- /dev/null +++ b/doc/superpowers/plans/2026-08-01-github-ci-hardening.md @@ -0,0 +1,1187 @@ +# GitHub CI 全面加固实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务实现此计划。使用复选框(`- [ ]`)跟踪步骤;每个任务都要先取得失败或缺口证据,再实施最小改动并验证。不得修改 PR #108 的 Getty 运行时源码,不得修改 branch protection,不得 force push。 + +**目标:** 在 PR #108 中把 Getty 的 GitHub CI 改造成可复现、失败可传播、最小权限、供应链可审计的门禁,并增加 race、真实跨平台构建、CodeQL 与 Dependabot;同时删除已失效且暴露明文凭据的 Travis 配置。 + +**架构:** 主 `CI` workflow 包含 license、Test and Lint、隔离的 Upload Coverage、race 和三平台构建 5 个逻辑 job;独立 `CodeQL` workflow 负责安全分析;Dependabot 负责 Go module 与 GitHub Actions 更新。Makefile 提供本地与 CI 共用的确定性入口。所有 Action 固定到核验过的完整 commit SHA,Go 缓存只由 `setup-go` 管理,coverage 先通过 artifact 在 job 间传递,再由只拥有 OIDC 权限的上传 job 调用 Codecov,并在任一阶段失败时使 check 失败。 + +**技术栈:** GitHub Actions、Go 1.25、GNU Make/Bash、`actionlint v1.7.12`、Codecov Action v7 OIDC(CLI 固定 `v11.3.1`)、GitHub CodeQL Action v3、Dependabot、WSL/Linux 与 GitHub-hosted Ubuntu/Windows/macOS runner。 + +--- + +## 文件结构与职责 + +- 修改 `.github/workflows/github-actions.yml`:主 CI 门禁、最小权限、并发取消、超时、唯一缓存、coverage artifact 与隔离 OIDC 上传、race 与三平台构建。 +- 新增 `.github/workflows/codeql.yml`:Go CodeQL pull request、push 和每周扫描。 +- 新增 `.github/dependabot.yml`:Go module 与 GitHub Actions 的受控自动更新。 +- 修改 `Makefile`:确定性 `test`、只读结果门禁 `check-fmt`、独立 `test-race` 和固定工具版本。 +- 修改 `README.md`:将 Travis badge 替换为 GitHub Actions `CI` badge。 +- 修改 `README_CN.md`:同步英文 README 的 CI badge。 +- 删除 `.travis.yml`:从当前树移除失效 Travis 配置及其中的明文凭据;不复述、不调用凭据。 +- 保留 `doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md`:用户已批准的设计边界和验收依据。 +- 新增本计划:记录逐步实现、验证、提交、push 和 GitHub 实时复检流程。 + +## 固定基准 + +实现开始前重新查询;只有结果仍匹配时才能继续: + +```text +PR: AlexStocks/getty#108 +Base: master@cc9909dc9e0aab1307f553bc2a3d8400161be4e2 +Remote Head branch: codex/fix-issue-97-remaining +Remote Head SHA: 087714342a09f1cc2318bee9d570c2b6ed028044 +Approved design commit: 7539afef7d495ed43f94e0ae488d8da010b9a7f5 +``` + +本次核验的 Action 提交: + +```text +actions/checkout@v7: 3d3c42e5aac5ba805825da76410c181273ba90b1 +actions/setup-go@v7.0.0: b7ad1dad31e06c5925ef5d2fc7ad053ef454303e +apache/skywalking-eyes official main verified commit (post-v0.8.0): 315732dd4b8d3a015d8d9b91936b935a0b854817 +actions/upload-artifact@v7.0.1: 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a +actions/download-artifact@v8.0.1: 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c +codecov/codecov-action@v7: fb8b3582c8e4def4969c97caa2f19720cb33a72f +github/codeql-action@v3: a2983b8bed1923f44751c5c43237f479442827b3 +``` + +即使计划中记录了 SHA,实施时也必须再次通过 GitHub API 查询对应版本引用;若上游引用移动,记录新旧值、核验 release/tag 后再更新计划内实际使用值,不得静默使用过期或未知提交。 + +正式质量审查已同时核对 `upload-artifact` v7.0.1 和 `download-artifact` v8.0.1 的 GitHub release 元数据与精确 tag ref,上述 SHA 均双向一致。`download-artifact` v8.0.1 tag 下 README 第 48 行仍保留一处 `@v7` 示例,但同一 release/ref 明确指向 v8.0.1 的上述提交;该示例按文档滞后处理,不覆盖 release/ref 证据。 + +Action 固定默认要求官方稳定 release/tag 与完整 SHA 对应。窄例外是:official main 的 verified commit 明确晚于最新 release,并且回退 release 会撤销安全或可复现性加固;此时必须记录 ancestry、差异和完整 SHA,仍禁止 `@main` 等可变 ref。本轮 `setup-go` v7.0.0/v7 均解析为 `b7ad1dad31e06c5925ef5d2fc7ad053ef454303e`,action.yml 与 v6.5.0 的输入、输出和 Node 24 runtime 不变,所以只替换 SHA。SkyWalking Eyes v0.8.0 解析为 `61275cc80d0798a405cb070f7d3a8aaf7cf2c2c1`,而保留的 `315732dd4b8d3a015d8d9b91936b935a0b854817` 是 official main verified commit,位于其后 27 个提交,已固定内部 `setup-go` 并硬化 shell 输入;不得为了满足 release 标签而降级。 + +`actions/checkout@v7` 官方 `action.yml` 支持 `persist-credentials`,默认值为 `true`。本计划的 5 个 checkout 后续只执行本地读取、构建、测试或使用各 Action 自身 token 的 API 上传,不需要 Git remote credential,因此全部显式设置 `persist-credentials: false`。CodeQL `init`/`analyze` 默认使用 `${{ github.token }}`,不依赖 checkout 持久化认证;不得为此扩大 workflow 或 job permissions。 + +### 任务 1:实时租约与旧门禁缺口基线 + +**文件:** +- 读取:PR #108 GitHub 实时状态 +- 写入证据:`D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-preflight.json` +- 写入证据:`D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-old-policy-gaps.txt` + +- [ ] **步骤 1:确认 PR 仍可实施且远端 Head 未漂移** + +在 PowerShell 中运行: + +```powershell +gh pr view 108 --repo AlexStocks/getty ` + --json number,state,headRefName,headRefOid,baseRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup + +gh pr view 108 --repo AlexStocks/getty ` + --json state,headRefName,headRefOid,baseRefName ` + --jq 'select(.state == "OPEN" and .headRefName == "codex/fix-issue-97-remaining" and .headRefOid == "087714342a09f1cc2318bee9d570c2b6ed028044" and .baseRefName == "master") | .headRefOid' +``` + +完整保留第一条命令的 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/ci-preflight.json`;命令本身不得重定向或创建证据文件。预期:第二条命令只输出 `087714342a09f1cc2318bee9d570c2b6ed028044`。没有输出或 SHA 不同即停止,不得 push;先 fetch 并增量审查远端新增提交。 + +- [ ] **步骤 2:确认本地提交链只建立在远端 Head 上** + +```powershell +git fetch origin codex/fix-issue-97-remaining +git merge-base --is-ancestor origin/codex/fix-issue-97-remaining HEAD +git log --oneline origin/codex/fix-issue-97-remaining..HEAD +git status --short --branch +``` + +预期:ancestor 检查退出码为 0;日志仅包含批准设计和本计划提交;工作树干净。 + +- [ ] **步骤 3:保存旧配置缺口的可复验基线** + +```powershell +'--- workflow gaps ---' +rg -n 'setup-go@|actions/cache@|codecov\.io/bash|@main|permissions:|concurrency:|timeout-minutes:|-race' .github\workflows\github-actions.yml +'--- makefile gaps ---' +rg -n 'go env -w|go test|imports-formatter@|check-fmt|test-race' Makefile +'--- travis references ---' +rg -n 'travis-ci' README.md README_CN.md +``` + +完整保留命令 stdout/stderr,再由 agent 使用 `apply_patch` 写入 `evidence/ci-old-policy-gaps.txt`。预期:证据能定位 setup-go 在 checkout 前、第二套 cache、远程 Codecov bash uploader、`@main`、`go env -w`、`@latest` 和 Travis badge。不得把 `.travis.yml` 中的凭据值写入证据。 + +### 任务 2:Makefile 确定性门禁 + +**文件:** +- 修改:`Makefile:24-55` +- 验证副本:`D:\test\github\review\AlexStocks-getty-pr-108\probes\ci-makefile-gates` + +- [ ] **步骤 1:证明当前 Makefile 缺少新入口且会写用户级 Go 配置** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +make -n test +check_fmt_exit=0 +make -n check-fmt || check_fmt_exit=$? +test_race_exit=0 +make -n test-race || test_race_exit=$? +printf 'CHECK_FMT_EXIT=%d\n' "$check_fmt_exit" +printf 'TEST_RACE_EXIT=%d\n' "$test_race_exit" +test "$check_fmt_exit" -ne 0 +test "$test_race_exit" -ne 0 +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'Makefile baseline probe failed' } +``` + +完整保留 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/make-test-before.txt`。预期:`make -n test` 输出包含 `go env -w GOTOOLCHAIN=...`;`check-fmt` 与 `test-race` 报 `No rule to make target`,且两个记录的退出码均非零。 + +- [ ] **步骤 2:补全 phony、help 与确定性目标** + +将 Makefile 中目标声明和命令调整为: + +```make +.PHONY: help test test-race fmt check-fmt clean lint install-golangci-lint install-imports-formatter + +help: + @echo "Available commands:" + @echo " test - Run unit tests with coverage" + @echo " test-race - Run transport tests with the race detector" + @echo " fmt - Format code" + @echo " check-fmt - Verify formatting without modifying tracked files" + @echo " lint - Run go vet and golangci-lint" + @echo " clean - Clean generated test files" + +# Run unit tests with a command-scoped toolchain selection. +test: clean + GOTOOLCHAIN=go1.25.0+auto go test ./... -count=1 -coverprofile=coverage.txt -covermode=atomic + +# Run the concurrency-sensitive transport package under the race detector. +test-race: + GOTOOLCHAIN=go1.25.0+auto go test -race ./transport -count=1 + +fmt: install-imports-formatter + go fmt ./... && GOROOT=$(shell go env GOROOT) imports-formatter + +check-fmt: install-imports-formatter + @temp_dir=$$(mktemp -d /tmp/getty-check-fmt.XXXXXX); \ + trap 'case "$$temp_dir" in /tmp/getty-check-fmt.*) rm -rf -- "$$temp_dir" ;; esac' EXIT; \ + mkdir -p "$$temp_dir/.git"; \ + tracked_files="$$temp_dir/.git/tracked-files.z"; \ + go_files="$$temp_dir/.git/go-files.z"; \ + git ls-files -z > "$$tracked_files"; \ + git ls-files -z -- '*.go' > "$$go_files"; \ + while IFS= read -r -d '' file; do \ + mkdir -p "$$temp_dir/$$(dirname "$$file")"; \ + cp -p -- "$$file" "$$temp_dir/$$file"; \ + done < "$$tracked_files"; \ + (cd "$$temp_dir" && \ + GOTOOLCHAIN=go1.25.0+auto go fmt ./... && \ + GOROOT="$$(GOTOOLCHAIN=go1.25.0+auto go env GOROOT)" \ + imports-formatter --path "$$temp_dir" --module github.com/AlexStocks/getty); \ + status=0; \ + while IFS= read -r -d '' file; do \ + current_hash=$$(git hash-object --path="$$file" "$$file"); \ + formatted_hash=$$(git hash-object --path="$$file" "$$temp_dir/$$file"); \ + if test "$$current_hash" != "$$formatted_hash"; then \ + printf 'Formatting changes are required: %s\n' "$$file"; \ + status=1; \ + fi; \ + done < "$$go_files"; \ + exit "$$status" + +# Clean generated test files. +clean: + rm -rf coverage.txt + +# Run golangci-lint. +lint: install-golangci-lint + go vet ./... + golangci-lint run ./... --timeout=10m + +install-golangci-lint: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 + +install-imports-formatter: + go install github.com/dubbogo/tools/cmd/imports-formatter@v1.0.10 +``` + +不要修改 `.DEFAULT_GOAL`、`.SHELLFLAGS` 或当前清理文件范围。`test` 不得调用 `go env -w`。 + +- [ ] **步骤 3:验证命令展开没有全局写入且版本固定** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +make -n test test-race install-imports-formatter +if grep -En 'go env -w|imports-formatter@latest' Makefile; then + printf 'forbidden Makefile pattern found\n' >&2 + exit 1 +fi +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'Makefile policy validation failed' } +``` + +完整保留 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/make-targets-after.txt`。预期:输出包含命令级 `GOTOOLCHAIN=go1.25.0+auto`、两个 `-count=1` 和 `imports-formatter@v1.0.10`,反向检索无匹配。 + +- [ ] **步骤 4:提交 Makefile 改动** + +```powershell +git diff --check -- Makefile +git add Makefile +git commit -m "build: make CI checks deterministic" +``` + +预期:只提交 `Makefile`。 + +### 任务 3:重写主 CI workflow + +**文件:** +- 修改:`.github/workflows/github-actions.yml` + +- [ ] **步骤 1:建立会使旧 workflow 失败的政策检查** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +workflow=.github/workflows/github-actions.yml +policy_exit=0 +if grep -Eq "actions/cache@|codecov.io/bash|@(main|latest)([[:space:]#]|$)" "$workflow"; then + policy_exit=11 +else + first_checkout=$(grep -n "uses: actions/checkout@" "$workflow" | head -1 | cut -d: -f1) + first_setup=$(grep -n "uses: actions/setup-go@" "$workflow" | head -1 | cut -d: -f1) + if ! test "$first_checkout" -lt "$first_setup"; then policy_exit=12; fi +fi +printf 'POLICY_EXIT=%d\n' "$policy_exit" +test "$policy_exit" -ne 0 +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'old workflow policy probe did not observe the expected failure' } +``` + +预期:在修改前失败,原因至少包括旧 `actions/cache`、远程 uploader、`@main` 或 setup-go 排在 checkout 前。 + +- [ ] **步骤 2:用完整内容替换主 workflow** + +`.github/workflows/github-actions.yml` 应为: + +```yaml +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + license: + name: Check License Header + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Check License Header + uses: apache/skywalking-eyes/header@315732dd4b8d3a015d8d9b91936b935a0b854817 + with: + config: .licenserc.yaml + mode: check + + test-and-lint: + name: Test and Lint + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify + + - name: Check Code Format + run: make check-fmt + + - name: Unit Test + run: make test + + - name: Lint + run: make lint + + - name: Upload Coverage Artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: coverage + path: coverage.txt + if-no-files-found: error + retention-days: 1 + + coverage: + name: Upload Coverage + needs: test-and-lint + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + steps: + - name: Download Coverage Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: coverage + + - name: Upload Coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f + with: + version: v11.3.1 + use_oidc: true + fail_ci_if_error: true + files: ./coverage.txt + disable_search: true + + race: + name: Race + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify + + - name: Race Test + run: make test-race + + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify Go Modules + run: go mod verify + + - name: Build + run: go build ./... +``` + +不得恢复独立 `actions/cache`、CodeCov bash uploader 或任何可变 Action 引用。4 个主 CI checkout 都显式设置 `persist-credentials: false`,因为后续 step 不需要 Git remote 认证。License job 不需要显式 `GITHUB_TOKEN` 环境变量;GitHub 会为 Action 提供最小权限 token 上下文。 + +- [ ] **步骤 3:运行政策检查并验证全部 Action 引用** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +workflow=.github/workflows/github-actions.yml +if grep -En "actions/cache@|codecov.io/bash|@(main|latest)([[:space:]#]|$)" "$workflow"; then + exit 1 +fi +first_checkout=$(grep -n "uses: actions/checkout@" "$workflow" | head -1 | cut -d: -f1) +first_setup=$(grep -n "uses: actions/setup-go@" "$workflow" | head -1 | cut -d: -f1) +test "$first_checkout" -lt "$first_setup" +python3 - <<"PY" +import pathlib +import re + +paths = [pathlib.Path(".github/workflows/github-actions.yml")] +workflow_text = "\n".join(path.read_text(encoding="utf-8") for path in paths) +for path in paths: + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = re.search(r"\buses:\s+\S+@([^\s#]+)", line) + if match and not re.fullmatch(r"[0-9a-f]{40}", match.group(1)): + raise SystemExit(f"{path}:{number}: mutable action ref {match.group(1)}") +checkout_pattern = r"(?m)^\s+uses:\s+actions/checkout@[0-9a-f]{40}(?:\s+#.*)?\s*$" +checkout_false_pattern = checkout_pattern + r"\n\s+with:\s*$\n\s+persist-credentials:\s+false\s*$" +checkout_count = len(re.findall(checkout_pattern, workflow_text)) +checkout_false_count = len(re.findall(checkout_false_pattern, workflow_text)) +checkout_true_count = len(re.findall(r"(?m)^\s+persist-credentials:\s+true\s*$", workflow_text)) +if checkout_count != 4 or checkout_false_count != 4 or checkout_true_count != 0: + raise SystemExit( + f"checkout credential policy mismatch: total={checkout_count}, " + f"false={checkout_false_count}, true={checkout_true_count}" + ) +PY +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'main workflow policy validation failed' } +``` + +预期:退出码 0;无 mutable ref、重复 cache 或远程 shell uploader。主 CI 的 4 个 checkout 都必须显式设置 `persist-credentials: false`,不得出现 `true` 或缺失项;主 CI 应恰好包含 5 个逻辑 job 和 11 个 `uses:`;`id-token: write` 只位于 `Upload Coverage`,Codecov `version` 为 `v11.3.1`。 + +- [ ] **步骤 4:提交主 workflow** + +```powershell +git diff --check -- .github/workflows/github-actions.yml +git add .github/workflows/github-actions.yml +git commit -m "ci: harden tests race and platform builds" +``` + +预期:只提交主 workflow。 + +### 任务 4:新增 CodeQL workflow + +**文件:** +- 新增:`.github/workflows/codeql.yml` + +- [ ] **步骤 1:证明当前仓库没有 CodeQL workflow** + +```powershell +Test-Path .github\workflows\codeql.yml +rg -n 'github/codeql-action' .github\workflows +``` + +预期:`Test-Path` 输出 `False`,`rg` 无匹配并返回 1。 + +- [ ] **步骤 2:新增固定 SHA、最小权限的 Go CodeQL workflow** + +```yaml +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: '30 1 * * 1' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (Go) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@a2983b8bed1923f44751c5c43237f479442827b3 # v3 + with: + languages: go + build-mode: autobuild + + - name: Analyze + uses: github/codeql-action/analyze@a2983b8bed1923f44751c5c43237f479442827b3 # v3 +``` + +当前官方形状为 `init` 中声明 `build-mode: autobuild` 后直接执行 `analyze`。CodeQL checkout 显式设置 `persist-credentials: false`;后续 `init`/`analyze` 使用自身默认的 `${{ github.token }}`,不依赖本地 Git credential。旧的 `init(build-mode: autobuild) -> autobuild -> analyze` 三步写法仍兼容,但显式 `autobuild` 与 init 的 build mode 重复,属于冗余,不应保留在可复现计划中。 + +- [ ] **步骤 3:用 actionlint 验证两个 workflow** + +```bash +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- bash -lc \ + "GOTOOLCHAIN=go1.25.0+auto go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color" +``` + +预期:退出码 0,无 workflow 语法、表达式、shell 或 action 输入错误。若 actionlint 对 action metadata 的远程可见性有限,不能把该限制当成 GitHub 运行成功证据;仍须等待远端 check。 + +- [ ] **步骤 4:提交 CodeQL workflow** + +```powershell +git add .github/workflows/codeql.yml +git commit -m "ci: add CodeQL analysis" +``` + +预期:只提交 `codeql.yml`。 + +### 任务 5:新增 Dependabot 配置并严格解析 YAML + +**文件:** +- 新增:`.github/dependabot.yml` +- 新增临时验证器:`D:\test\github\review\AlexStocks-getty-pr-108\probes\validate-dependabot-yaml.go` + +- [ ] **步骤 1:证明当前仓库没有 Dependabot 配置** + +```powershell +Test-Path .github\dependabot.yml +``` + +预期:输出 `False`。 + +- [ ] **步骤 2:新增受控更新配置** + +```yaml +version: 2 +updates: + - package-ecosystem: gomod + directory: / + target-branch: master + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + target-branch: master + schedule: + interval: monthly + open-pull-requests-limit: 3 + commit-message: + prefix: ci +``` + +- [ ] **步骤 3:用仓库已有 YAML 依赖执行严格解析和结构断言** + +在镜像 `probes` 目录通过 `apply_patch` 创建以下一次性验证器,不提交到 PR: + +```go +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v2" +) + +type config struct { + Version int `yaml:"version"` + Updates []struct { + Ecosystem string `yaml:"package-ecosystem"` + Directory string `yaml:"directory"` + Target string `yaml:"target-branch"` + Schedule struct { + Interval string `yaml:"interval"` + } `yaml:"schedule"` + Limit int `yaml:"open-pull-requests-limit"` + CommitMessage struct { + Prefix string `yaml:"prefix"` + } `yaml:"commit-message"` + } `yaml:"updates"` +} + +func main() { + data, err := os.ReadFile(".github/dependabot.yml") + if err != nil { + panic(err) + } + var cfg config + if err := yaml.UnmarshalStrict(data, &cfg); err != nil { + panic(err) + } + if cfg.Version != 2 || len(cfg.Updates) != 2 { + panic(fmt.Sprintf("unexpected Dependabot structure: %+v", cfg)) + } + want := map[string]struct { + interval string + limit int + prefix string + }{ + "gomod": {interval: "weekly", limit: 5, prefix: "deps"}, + "github-actions": {interval: "monthly", limit: 3, prefix: "ci"}, + } + for _, update := range cfg.Updates { + expected, ok := want[update.Ecosystem] + if !ok || update.Directory != "/" || update.Target != "master" || + update.Schedule.Interval != expected.interval || update.Limit != expected.limit || + update.CommitMessage.Prefix != expected.prefix { + panic(fmt.Sprintf("unexpected update entry: %+v", update)) + } + delete(want, update.Ecosystem) + } + if len(want) != 0 { + panic(fmt.Sprintf("missing ecosystems: %+v", want)) + } +} +``` + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- bash -lc \ + "GOTOOLCHAIN=go1.25.0+auto go run ../probes/validate-dependabot-yaml.go" +``` + +预期:退出码 0,无输出;严格解析会拒绝未知字段。 + +- [ ] **步骤 4:提交 Dependabot 配置** + +```powershell +git add .github/dependabot.yml +git commit -m "ci: add Dependabot updates" +``` + +预期:只提交 `.github/dependabot.yml`;临时验证器留在镜像 `probes`,不进入 Git index。 + +### 任务 6:替换 badge 并删除 Travis 当前树配置 + +**文件:** +- 修改:`README.md:5` +- 修改:`README_CN.md:5` +- 删除:`.travis.yml` + +- [ ] **步骤 1:再次确认 Travis 不在当前 PR checks 中** + +```powershell +$checks = gh pr view 108 --repo AlexStocks/getty --json statusCheckRollup --jq '.statusCheckRollup[].name' +$checks +if ($checks -match '(?i)travis') { throw 'Travis check is still active; stop deletion' } +``` + +预期:现有 check 名称中没有 Travis。若出现 Travis,停止删除并重新评估迁移覆盖。 + +- [ ] **步骤 2:只比较 Travis 命令范围,不输出敏感值** + +```powershell +Select-String -Path .travis.yml -Pattern '^language:|^os:|^go:|^install:|^script:|^after_success:|^\s*-\s+(go|make)\s' | + ForEach-Object { '{0}:{1}' -f $_.LineNumber,$_.Line.Trim() } +``` + +预期:有效范围为格式、测试/coverage 和 race;新主 workflow/Makefile 已覆盖这些门禁,并额外增加 lint、模块验证与跨平台构建。不得运行、复制或打印 uploader/webhook 行。 + +- [ ] **步骤 3:替换两个 README badge** + +把两个 README 的 Travis badge 行替换为: + +```markdown +[![CI](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml/badge.svg?branch=master)](https://github.com/AlexStocks/getty/actions/workflows/github-actions.yml) +``` + +- [ ] **步骤 4:删除 `.travis.yml`** + +使用 `apply_patch` 删除整个文件。删除仅从当前树移除凭据,不能清除 Git 历史;不得在 commit message 或 PR 评论中复制任何 token。 + +- [ ] **步骤 5:验证当前树没有 Travis 引用和已知敏感配置键** + +```powershell +if (Test-Path .travis.yml) { throw '.travis.yml still exists' } +if (rg -n 'travis-ci' README.md README_CN.md) { throw 'Travis badge remains' } +rg -n 'actions/workflows/github-actions\.yml/badge\.svg' README.md README_CN.md +``` + +预期:前两个检查通过;最后一条在两个 README 各匹配一次。 + +- [ ] **步骤 6:提交 README 与 Travis 清理** + +```powershell +git add README.md README_CN.md .travis.yml +git commit -m "docs: replace Travis CI references" +``` + +预期:提交包含两个 badge 替换和 `.travis.yml` 删除,不包含其他文件。 + +### 任务 7:本地静态、变异和 Go 验证 + +**文件:** +- 读取:全部实施文件 +- 验证副本:`D:\test\github\review\AlexStocks-getty-pr-108\probes\check-fmt-readonly-crlf` +- 验证副本:`D:\test\github\review\AlexStocks-getty-pr-108\probes\check-fmt-readonly-gofmt` +- 验证副本:`D:\test\github\review\AlexStocks-getty-pr-108\probes\check-fmt-readonly-imports` +- 验证副本:`D:\test\github\review\AlexStocks-getty-pr-108\probes\check-fmt-readonly-failure` +- 输出:agent 仅使用 `apply_patch` 写入 `D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-local-validation-*.txt`;验证命令只向调用端返回完整 stdout/stderr + +- [ ] **步骤 1:对全部 workflow 运行 actionlint** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +shopt -s nullglob +workflows=(.github/workflows/*.yml .github/workflows/*.yaml) +printf 'ACTIONLINT_FILE_COUNT=%d\n' "${#workflows[@]}" +test "${#workflows[@]}" -gt 0 +go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color "${workflows[@]}" +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'actionlint validation failed' } +``` + +预期:退出码 0,无诊断。 + +- [ ] **步骤 2:执行 workflow 供应链政策检查** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +if grep -ERn "actions/cache@|codecov.io/bash|curl[[:space:]].*\|[[:space:]]*(ba)?sh|@(main|master|latest)([[:space:]#]|$)" .github/workflows Makefile; then + exit 1 +fi +python3 - <<"PY" +import pathlib +import re + +paths = list(pathlib.Path(".github/workflows").glob("*.yml")) +paths += list(pathlib.Path(".github/workflows").glob("*.yaml")) +workflow_text = "\n".join(path.read_text(encoding="utf-8") for path in paths) +for path in paths: + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = re.search(r"\buses:\s+\S+@([^\s#]+)", line) + if match and not re.fullmatch(r"[0-9a-f]{40}", match.group(1)): + raise SystemExit(f"{path}:{number}: mutable action ref {match.group(1)}") +checkout_pattern = r"(?m)^\s+uses:\s+actions/checkout@[0-9a-f]{40}(?:\s+#.*)?\s*$" +checkout_false_pattern = checkout_pattern + r"\n\s+with:\s*$\n\s+persist-credentials:\s+false\s*$" +checkout_count = len(re.findall(checkout_pattern, workflow_text)) +checkout_false_count = len(re.findall(checkout_false_pattern, workflow_text)) +checkout_true_count = len(re.findall(r"(?m)^\s+persist-credentials:\s+true\s*$", workflow_text)) +if checkout_count != 5 or checkout_false_count != 5 or checkout_true_count != 0: + raise SystemExit( + f"checkout credential policy mismatch: total={checkout_count}, " + f"false={checkout_false_count}, true={checkout_true_count}" + ) +PY +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'workflow supply-chain policy validation failed' } +``` + +预期:退出码 0。注释中的版本标签允许存在,但 `uses:` 的实际 ref 必须是完整 SHA。5 个 checkout 必须全部显式设置 `persist-credentials: false`,且后续 step 扫描不得发现 `git fetch`、`git push`、remote、submodule 等认证需求。当前最终文件应为 6 个逻辑 job、14 个 `uses:`;主 CI 应为 5 个逻辑 job;`id-token: write` 只出现于 `Upload Coverage`;Codecov `version` 必须为 `v11.3.1`。 + +- [ ] **步骤 3:证明 LF 与 CRLF clean 输入均为只读绿灯** + +在 LF clean 的主验证副本运行 `make check-fmt`,并在命令前后分别记录 tracked Go 文件的聚合字节哈希、`git status --porcelain=v2 --branch --untracked-files=all` 和 `/tmp/getty-check-fmt.*` 列表。预期:退出码 0,三个值前后完全一致。 + +再从固定 seed 建立 `core.autocrlf=true` 的 clean CRLF Git probe,确认 `git ls-files --eol -- '*.go'` 为 `i/lf w/crlf`。运行 `make check-fmt` 后再次核对原始字节哈希、Git clean 状态、物理 EOL 和临时目录列表。预期:退出码 0,文件仍为 CRLF,状态仍 clean,原始字节哈希不变且无临时目录残留。这一门禁证明 clean-filter hash 比较不会把 CRLF/LF 的工作树表示差异误报为格式错误。 + +- [ ] **步骤 4:用两类格式变异和 formatter 故障证明非零传播** + +建立三个独立 probe,所有变异只通过 `apply_patch` 写入 probe: + +1. `check-fmt-readonly-gofmt`:保持 import blocks 已符合项目规则,只制造 `gofmt -d` 可见的函数空格差异。先在临时副本单独执行 imports-formatter,证明 clean-filter hash 不变;再运行 `make check-fmt`,预期列出变异文件并非零退出。 +2. `check-fmt-readonly-imports`:构造 gofmt 已接受的单一 import block,但把标准库和项目内部 import 混在同一组。先确认 `gofmt -d` 输出为空,再在临时副本单独执行 imports-formatter,证明 clean-filter hash 改变;运行 `make check-fmt`,预期列出变异文件并非零退出。 +3. `check-fmt-readonly-failure`:通过导出的同名 Bash function 让 imports-formatter 明确返回 23。运行 `make check-fmt`,预期 formatter 错误向 make 非零传播,且 `/tmp/getty-check-fmt.*` 前后列表一致。 + +另增加两个生产者故障注入门禁:让第一个 `git ls-files -z` 在输出部分或全部 tracked 路径后返回 38,确认 `make check-fmt` 非零且 formatter 未执行;让第二个 `git ls-files -z -- '*.go'` 零输出后返回 37,确认目标仍非零。两个枚举命令必须先把 NUL 清单写入临时镜像的 `.git/` 元数据目录,再由循环通过普通文件重定向读取;禁止使用会隐藏生产者退出码的 process substitution。两个故障探针都必须证明 source 字节哈希和 Git 状态不变,且 trap 没有留下临时目录。 + +每个 probe 都必须记录被测文件的原始字节哈希和 Git 状态,并证明运行前后完全一致;不能因为检查失败而允许 formatter 改写变异文件。完整 stdout/stderr 由 agent 使用 `apply_patch` 写入 `evidence/ci-local-validation-check-fmt-readonly.txt`。 + +- [ ] **步骤 5:运行模块、测试、race 与 lint** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +go version +go mod verify +make test +make test-race +make lint +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'Go validation failed' } +``` + +完整保留 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/ci-local-validation-go.txt`。预期:Go 为 `go1.25.1 linux/amd64`;所有命令退出码 0;`coverage.txt` 是唯一预期生成文件。若失败,先按 `superpowers:systematic-debugging` 区分 PR 新增、Base 既有和环境问题,不得跳过失败。 + +- [ ] **步骤 6:执行跨编译补充验证** + +```powershell +$bash = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto +GOOS=windows GOARCH=amd64 go build ./... +GOOS=darwin GOARCH=amd64 go build ./... +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./... +CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build ./... +'@ +$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($bash)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- /bin/bash -c "printf '%s' '$encoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 0) { throw 'cross-build validation failed' } +``` + +完整保留 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/ci-local-validation-cross-build.txt`。预期:退出码 0。交叉编译只是补充证据,不能替代远端 Windows/macOS runner。 + +- [ ] **步骤 7:检查 diff、index、意外文件和敏感值回流** + +```powershell +git diff --check origin/codex/fix-issue-97-remaining...HEAD +git status --short --branch +git diff --name-status origin/codex/fix-issue-97-remaining...HEAD +git diff --stat origin/codex/fix-issue-97-remaining...HEAD +git grep -n -I -E 'travis-ci|codecov\.io/bash|go env -w GOTOOLCHAIN|imports-formatter@latest' -- . ':!doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md' ':!doc/superpowers/plans/2026-08-01-github-ci-hardening.md' +``` + +预期:除 `coverage.txt` 外 source worktree无非预期生成文件;实际实施文件严格匹配设计。最后一条仅允许历史说明文档中的证据性文字,不允许生产配置出现旧模式。检查输出时不得复述任何已删除凭据。 + +### 任务 8:实现复核与必要修正 + +**文件:** +- 复核:本计划列出的全部变更文件 +- 可能修改:只限设计和计划文档,用于同步已通过正式质量审查的实现偏差 +- 阻塞边界:若 CI、Makefile、Dependabot、README 或 Go 源码仍有 Critical/Important 问题,报告 `BLOCKED`,不得在本任务中自行修改实现文件 + +- [ ] **步骤 1:逐项对照批准设计的完成标准** + +核对: + +```text +[ ] 唯一 Go cache owner 是 setup-go v7.0.0 +[ ] checkout 位于 setup-go 前 +[ ] 全部 5 个 checkout 显式设置 persist-credentials:false,后续 step 无 Git remote 认证需求 +[ ] 主 CI 恰好 5 个逻辑 job:License、Test and Lint、Upload Coverage、Race、Build matrix +[ ] Test and Lint 无 OIDC,使用 upload-artifact v7.0.1 上传 coverage(missing=error、retention=1) +[ ] Upload Coverage needs test-and-lint,权限只有 id-token:write,不 checkout/setup-go/run +[ ] Upload Coverage 使用 download-artifact v8.0.1 后调用 Codecov;CLI 固定 v11.3.1,显式 coverage 文件并启用 fail_ci_if_error +[ ] Race 独立执行 transport race +[ ] Build matrix 使用真实 ubuntu/windows/macos runner +[ ] CodeQL 是独立 workflow,形状为 init(build-mode: autobuild) -> analyze,权限最小且 Action 固定 SHA +[ ] Dependabot 只有 gomod 与 github-actions 两个受控入口 +[ ] Makefile 无 go env -w、无浮动工具版本,测试禁用缓存 +[ ] 两个 README 使用 GitHub Actions badge +[ ] .travis.yml 从当前树删除 +[ ] 未修改运行时 Go 源码、branch protection 或 GitHub ruleset +[ ] 全部 workflow 合计 6 个逻辑 job、14 个 uses;id-token:write 只出现 1 次 +``` + +- [ ] **步骤 2:审阅提交边界和提交消息** + +```powershell +git log --reverse --stat --oneline origin/codex/fix-issue-97-remaining..HEAD +git show --check --stat HEAD +``` + +预期:每个提交单一目的;没有凭据、生成二进制、`coverage.txt`、probe 或 evidence 进入提交。 + +- [ ] **步骤 3:如果复核发现 CI 配置问题,先取得失败证据再修正** + +只允许修正设计和计划文档,使代码/YAML 片段、job/uses 数量、权限断言、Codecov 版本和 required checks 建议与最终实现一致。每次修正后运行文档直接相关的 `git diff --check`、代码围栏/占位符检查和旧冲突模式扫描,并对全部 workflow 重跑 actionlint。 + +只暂存两份文档并使用具体消息: + +```powershell +git add doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md ` + doc/superpowers/plans/2026-08-01-github-ci-hardening.md +git commit -m "docs: align CI design with reviewed implementation" +``` + +不得 amend 已提交历史,不得用 force push。 + +### 任务 9:push 前最终实时复检与普通 push + +**文件:** +- 读取:PR #108 实时状态、远端分支 SHA、本地提交链 +- 写入证据:`D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-pre-push-validation-raw.txt`,只能用 `apply_patch` 保存完整原始输出 +- 不修改:branch protection、ruleset、review threads + +- [ ] **步骤 1:执行 `verification-before-completion` 新鲜验证** + +必须重新运行 actionlint、Go 门禁和四个 cross-build,并让每条命令的边界、当前 HEAD、Go 版本、UTC 开始/结束时间和退出码出现在原始输出中。不得把多行 Bash 直接嵌入 `wsl.exe ... bash -lc` 参数;PowerShell 必须把完整 Bash wrapper 编码为 UTF-8 Base64,WSL 内再无损解码并交给 `/bin/bash`。 + +先运行 harmless probe,证明 Bash 变量和值以及预期非零退出码能完整穿过 PowerShell、WSL 和 Base64 解码边界: + +```powershell +$probeScript = @' +set -eu -o pipefail +printf 'PROBE_VALUE=%s\n' "$PROBE_VALUE" +printf 'PROBE_EXIT=%s\n' "$PROBE_EXIT" +exit "$PROBE_EXIT" +'@ +$probeEncoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($probeScript)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- ` + env PROBE_VALUE=base64-transport-ok PROBE_EXIT=7 ` + /bin/bash -c "printf '%s' '$probeEncoded' | base64 -d | /bin/bash" +if ($LASTEXITCODE -ne 7) { throw 'PowerShell-to-WSL Base64 probe did not preserve exit 7' } +``` + +预期原样输出 `PROBE_VALUE=base64-transport-ok` 和 `PROBE_EXIT=7`,PowerShell 观察到退出码 7。probe 不创建文件;任一值或退出码不一致都必须停止,不能继续正式验证。 + +probe 通过后运行正式 wrapper。actionlint 必须同时覆盖 `.yml` 与 `.yaml`: + +```powershell +$validationScript = @' +set -eu -o pipefail +export PATH=/home/alex/bin/go1.25/bin:/home/alex/go/bin:/usr/local/bin:/usr/bin:/bin +export GOTOOLCHAIN=go1.25.0+auto + +overall=0 +run_check() { + label=$1 + shift + printf '=== BEGIN %s ===\n' "$label" + printf 'BEGIN_UTC=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'HEAD=%s\n' "$(git rev-parse HEAD)" + printf 'GO_VERSION=%s\n' "$(go version)" + printf 'COMMAND=' + printf '%q ' "$@" + printf '\n' + if "$@"; then + rc=0 + else + rc=$? + overall=1 + fi + printf 'EXIT=%d\n' "$rc" + printf 'END_UTC=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '=== END %s ===\n' "$label" +} + +run_actionlint() { + shopt -s nullglob + workflows=(.github/workflows/*.yml .github/workflows/*.yaml) + test "${#workflows[@]}" -gt 0 + go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 "${workflows[@]}" +} + +printf 'VALIDATION_BEGIN_UTC=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +printf 'VALIDATION_HEAD=%s\n' "$(git rev-parse HEAD)" +printf 'VALIDATION_GO_VERSION=%s\n' "$(go version)" +run_check actionlint run_actionlint +run_check go-mod-verify go mod verify +run_check go-test go test ./... -count=1 +run_check go-test-race go test -race ./transport -count=1 +run_check go-vet go vet ./... +run_check make-lint make lint +run_check build-windows-amd64 env GOOS=windows GOARCH=amd64 go build ./... +run_check build-darwin-amd64 env GOOS=darwin GOARCH=amd64 go build ./... +run_check build-linux-arm64 env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./... +run_check build-linux-riscv64 env CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 go build ./... +run_check git-diff-check git diff --check origin/codex/fix-issue-97-remaining...HEAD +run_check git-status git status --short --branch +printf 'VALIDATION_END_UTC=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +printf 'VALIDATION_EXIT=%d\n' "$overall" +exit "$overall" +'@ +$validationEncoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($validationScript)) +wsl.exe --cd /mnt/d/test/github/review/AlexStocks-getty-pr-108/source -- ` + /bin/bash -c "printf '%s' '$validationEncoded' | base64 -d | /bin/bash" +$validationExit = $LASTEXITCODE +if ($validationExit -ne 0) { throw "fresh CI validation failed with exit $validationExit" } +``` + +保留上述完整终端 stdout/stderr,包括测试、race、vet、lint 和 build 的所有正文。随后由 agent 使用 `apply_patch` 新增或完整替换 `ci-pre-push-validation-raw.txt`;验证命令和 wrapper 不得创建、追加或修改 evidence 文件,也不得用脚本摘要或人工改写后的“pass”列表代替 raw。若原始输出包含意外敏感值,先停止并报告,不得把该值写入证据。 + +预期:harmless probe 证明传输边界无损;每个正式边界的 `EXIT=0` 且最终 `VALIDATION_EXIT=0`;记录的 `VALIDATION_HEAD` 与待 push HEAD 一致。任一命令失败都会把 `overall` 置为非零并传播到 PowerShell。不得用较早日志替代该步骤的新鲜结果。 + +- [ ] **步骤 2:再次获取远端 Head 并执行显式 lease** + +```powershell +git fetch origin codex/fix-issue-97-remaining +$remoteHead = git rev-parse origin/codex/fix-issue-97-remaining +$liveHead = gh pr view 108 --repo AlexStocks/getty --json state,headRefOid --jq 'select(.state == "OPEN") | .headRefOid' +if ($remoteHead -ne '087714342a09f1cc2318bee9d570c2b6ed028044' -or $liveHead -ne $remoteHead) { + throw "Remote PR Head drifted; stop before push" +} +git merge-base --is-ancestor $remoteHead HEAD +``` + +预期:远端 Git ref、GitHub PR Head 和实施基准三者相同;ancestor 检查成功。 + +- [ ] **步骤 3:普通 push 当前分支** + +```powershell +git push origin HEAD:codex/fix-issue-97-remaining +``` + +预期:普通 fast-forward push 成功;不得添加 `--force` 或 `--force-with-lease`。 + +### 任务 10:等待并核验 GitHub 新 checks + +**文件:** +- 写入证据:agent 仅使用 `apply_patch` 更新 `D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-post-push-pr.json` +- 写入证据:agent 仅使用 `apply_patch` 更新 `D:\test\github\review\AlexStocks-getty-pr-108\evidence\ci-post-push-checks.txt` +- 写入证据:各 workflow/job 的完整无敏感值 stdout/stderr;GitHub 查询命令本身不得创建或修改文件 + +- [ ] **步骤 1:获取 push 后新 Head 和 workflow runs** + +```powershell +$newHead = gh pr view 108 --repo AlexStocks/getty --json headRefOid --jq .headRefOid +gh run list --repo AlexStocks/getty --branch codex/fix-issue-97-remaining --limit 20 ` + --json databaseId,workflowName,headSha,status,conclusion,url,createdAt ` + --jq ".[] | select(.headSha == \"$newHead\")" +``` + +预期:至少出现 `CI` 和 `CodeQL` 的新运行,Head 等于刚 push 的本地 `HEAD`。 + +- [ ] **步骤 2:等待当前 Head 的所有新运行完成** + +通过 API 逐个等待上一步返回的 run ID: + +```powershell +$headSha = gh pr view 108 --repo AlexStocks/getty --json headRefOid --jq .headRefOid +$runIds = gh run list --repo AlexStocks/getty --branch codex/fix-issue-97-remaining --limit 20 ` + --json databaseId,headSha --jq ".[] | select(.headSha == \"$headSha\") | .databaseId" +foreach ($runId in $runIds) { + gh run watch $runId --repo AlexStocks/getty --exit-status + if ($LASTEXITCODE -ne 0) { throw "GitHub Actions run $runId failed" } +} +``` + +预期:全部成功。若失败,下载精确失败 job 日志,按系统化调试区分配置、源码基线和外部服务问题;不得为了变绿而跳过门禁。 + +- [ ] **步骤 3:核对主 CI job 和真实 runner** + +```powershell +$headSha = gh pr view 108 --repo AlexStocks/getty --json headRefOid --jq .headRefOid +$ciRunId = gh run list --repo AlexStocks/getty --branch codex/fix-issue-97-remaining --workflow CI --limit 20 ` + --json databaseId,headSha --jq ".[] | select(.headSha == \"$headSha\") | .databaseId" | Select-Object -First 1 +if (-not $ciRunId) { throw 'CI run for current Head not found' } +gh run view $ciRunId --repo AlexStocks/getty --json headSha,status,conclusion,jobs,url +``` + +完整保留命令 stdout/stderr,再由 agent 使用 `apply_patch` 写入 `evidence/ci-post-push-checks.txt`。必须确认实际 job 包含并成功: + +```text +Check License Header +Test and Lint +Upload Coverage +Race +Build (ubuntu-latest) +Build (windows-latest) +Build (macos-latest) +``` + +同时从 `Test and Lint` 日志确认:setup-go 在 checkout 后读取 `go.mod`/`go.sum`,没有第二个 `actions/cache`,并成功上传名为 `coverage` 的 artifact。再从 `Upload Coverage` 日志确认:只下载该 artifact,Codecov CLI 为 `v11.3.1`,没有 HTTP 400、tokenless upload 错误或被吞掉的失败。 + +- [ ] **步骤 4:核对 CodeQL result 上传** + +```powershell +$headSha = gh pr view 108 --repo AlexStocks/getty --json headRefOid --jq .headRefOid +$codeqlRunId = gh run list --repo AlexStocks/getty --branch codex/fix-issue-97-remaining --workflow CodeQL --limit 20 ` + --json databaseId,headSha --jq ".[] | select(.headSha == \"$headSha\") | .databaseId" | Select-Object -First 1 +if (-not $codeqlRunId) { throw 'CodeQL run for current Head not found' } +gh run view $codeqlRunId --repo AlexStocks/getty --log-failed +gh run view $codeqlRunId --repo AlexStocks/getty --json headSha,status,conclusion,jobs,url +``` + +预期:`Analyze (Go)` 成功,Head 与 PR 最新 Head 一致。若 GitHub 安全设置阻止上传,记录准确错误和所需外部设置,不把它伪装成源码缺陷。 + +- [ ] **步骤 5:保存最终 PR 状态并复核 Head 未变化** + +```powershell +gh pr view 108 --repo AlexStocks/getty ` + --json number,state,headRefName,headRefOid,baseRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup +git rev-parse HEAD +gh pr view 108 --repo AlexStocks/getty --json headRefOid --jq .headRefOid +``` + +完整保留第一条命令 stdout/stderr,再由 agent 使用 `apply_patch` 写入 `evidence/ci-post-push-pr.json`。预期:本地 HEAD 与 GitHub PR Head 完全一致。 + +- [ ] **步骤 6:记录 Dependabot 的 PR 阶段验证边界** + +PR 分支上只复用任务 5 的严格 YAML 解析和精确结构断言;PR push、CI run 或 `gh pr view` 都不能证明 GitHub 已接受、启用或排程 Dependabot。将平台接受、启用状态以及是否按计划创建更新 PR 明确列为配置合并到默认分支 `master` 后的跟进验证,不得在任务 10 中写成已完成结果。 + +### 任务 11:最终 Review、required checks 建议与收尾对账 + +**文件:** +- 读取:当前 PR 完整 files/diff、review comments、checks、branch protection/rulesets +- 写入证据:agent 仅使用 `apply_patch` 更新 `D:\test\github\review\AlexStocks-getty-pr-108\evidence\files.json` 和 `evidence\pr.diff` +- 可能更新:`D:\test\github\arch-practice\alg\openclaw\review-experience.md` 或 `review-AlexStocks-getty.md`,仅当本轮产生经过验证的新经验 + +- [ ] **步骤 1:重新保存完整 PR 文件列表和 Diff** + +```powershell +gh api repos/AlexStocks/getty/pulls/108/files --paginate +gh pr diff 108 --repo AlexStocks/getty +``` + +分别完整保留两个命令的 stdout/stderr,由 agent 使用 `apply_patch` 写入 `evidence/files.json` 和 `evidence/pr.diff`。逐文件增量审查 CI 改动,确认没有运行时源码漂移。若发现本轮 CI 变更引入的可定位问题,先本地修复、重新验证、普通 push,再重复任务 10;不要给自己的 CI 改动留下明知的 P0/P1。 + +- [ ] **步骤 2:检索 review threads 并保留 UDP P1 独立状态** + +重新获取所有 review comments/threads,确认已存在的 UDP invalid-input 和生产调用路径测试缺口未因 CI 改造被误报为解决。除非用户另行授权,不修改 `transport/session.go`、`transport/session_test.go`,也不 resolve 对应线程。 + +- [ ] **步骤 3:读取而不修改 branch protection/rulesets** + +根据 push 后真实 check 名称输出建议 required checks;预计为: + +```text +Check License Header +Test and Lint +Upload Coverage +Race +Build (ubuntu-latest) +Build (windows-latest) +Build (macos-latest) +Analyze (Go) +``` + +实际名称以 GitHub API 返回为准。本任务禁止调用 branch protection/ruleset 写 API;需要用户单独授权。 + +- [ ] **步骤 4:明确外部凭据收尾** + +收尾必须要求仓库维护者在外部系统轮换或吊销旧 `.travis.yml` 中暴露的 Codecov upload token 和第三方 webhook access tokens。只说明凭据类型和风险,不复述值。删除当前文件不等于清除 Git 历史。 + +- [ ] **步骤 5:按工业 Review 协议输出最终对账** + +最终报告必须包含: + +```text +结论:PR #108 因现存 UDP P1 仍为 🚫 不可 Merge;CI 改造本身的 checks 结果单独列明。 +PR、镜像路径、WSL 路径、最终 Head、Base、分类。 +gh 与 rg/fd/grep/ls 调用次数。 +actionlint、YAML、Makefile、测试、race、lint、跨平台构建结果。 +远端 CI/CodeQL/Codecov 结果和 URL。 +Dependabot 在 PR 内只验证 YAML/结构;实际接受、启用和排程待合并默认分支后确认。 +已提交行内评论及去重说明。 +所有本轮 commit 和普通 push 结果。 +未修改 branch protection;给出建议 required checks 并请求单独授权。 +必须轮换/吊销的凭据类型。 +所有 evidence/probe/worktree 路径及是否可删除。 +Review 经验复利:实际记录内容,或“无新增可复用经验”。 +``` + +只有全部 CI 改造验证通过时,才能声称“CI 改造完成”;不得把这一结论扩展成 PR #108 可 Merge,因为 UDP P1 仍未修复。 diff --git a/doc/superpowers/plans/2026-08-01-issue-97-remaining-runtime-fixes.md b/doc/superpowers/plans/2026-08-01-issue-97-remaining-runtime-fixes.md new file mode 100644 index 00000000..67942012 --- /dev/null +++ b/doc/superpowers/plans/2026-08-01-issue-97-remaining-runtime-fixes.md @@ -0,0 +1,458 @@ +# Issue #97 剩余确定性运行时问题实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法跟踪进度。原始任务 1-3 保留历史完成状态;2026-08-17 review follow-up 已授权 commit 并 push 到现有 PR #108 分支,但未授权 merge。 + +**目标:** 修复 WSS 正常关闭 panic 和 UDP 接收缓冲区计算死分支,并用先失败、后通过的回归测试锁定行为。 + +**架构:** WSS event loop 在 `Serve` 返回处区分预期关闭与非预期错误,正常关闭安静退出、其他错误记录后退出。UDP buffer 规则提取为包内私有纯函数,由 `handleUDPPackage` 调用并通过表驱动边界测试验证。 + +**技术栈:** Go 1.25.1、标准库 `net/http`/`crypto/tls`、Getty transport 包、Go test/race detector、WSL/Linux。 + +--- + +## 文件结构 + +- 修改 `transport/server_test.go`:增加 WSS 启动后正常关闭的集成回归测试。 +- 修改 `transport/server.go`:将 WSS `Serve` 返回分类为预期关闭或需记录的运行错误。 +- 修改 `transport/session_test.go`:增加 UDP buffer 大小的表驱动边界测试和真实 UDP 接收路径测试。 +- 修改 `transport/session.go`:使 `udpReadBufferSize` 对非正值、溢出和 UDP 物理上限保持安全,并规范化 `SetMaxMsgLen` 输入。 +- 修改 `doc/superpowers/specs/2026-08-01-issue-97-remaining-runtime-fixes-design.md`:记录 review follow-up 的已批准设计。 +- 修改本计划文件:保留原始执行记录,并追加 review follow-up 的 TDD、变异和发布步骤。 + +### 任务 1:WSS 正常关闭回归测试与最小修复 + +**文件:** +- 修改:`transport/server_test.go:301-318` +- 修改:`transport/server.go:20-31` +- 修改:`transport/server.go:481-537` + +- [x] **步骤 1:编写失败的 WSS 正常关闭测试** + +在 `transport/server_test.go` 的 `TestServer` 后加入: + +```go +func TestWSSServerCloseDoesNotPanic(t *testing.T) { + certPath, err := filepath.Abs("../examples/profiles/wss/server_cert/server.crt") + if err != nil { + t.Fatal(err) + } + keyPath, err := filepath.Abs("../examples/profiles/wss/server_cert/server.key") + if err != nil { + t.Fatal(err) + } + + server := newServer( + WSS_SERVER, + WithLocalAddress("127.0.0.1:0"), + WithWebsocketServerPath("/ws"), + WithWebsocketServerCert(certPath), + WithWebsocketServerPrivateKey(keyPath), + ) + server.RunEventLoop(func(Session) error { return nil }) + + deadline := time.Now().Add(time.Second) + for { + server.lock.RLock() + serving := server.server != nil + server.lock.RUnlock() + if serving { + break + } + if time.Now().After(deadline) { + t.Fatal("WSS event loop did not publish its HTTP server") + } + time.Sleep(time.Millisecond) + } + + closed := make(chan struct{}) + go func() { + server.Close() + close(closed) + }() + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("WSS server Close did not return") + } +} +``` + +该测试使用真实 listener、真实 TLS 证书和真实 `http.Server`,不 mock Getty 内部实现;它专门回归 Issue #97 的正常关闭 panic。 + +- [x] **步骤 2:运行测试,确认红灯来自当前 WSS panic** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./transport -run '^TestWSSServerCloseDoesNotPanic$' -count=1" +``` + +预期:FAIL,进程输出包含 `panic: http: Server closed`,证明测试命中了当前 `runWSSEventLoop` 的无条件 panic;如果失败来自证书、端口或启动超时,先修正测试夹具并重新取得正确红灯。 + +- [x] **步骤 3:实现最小 WSS 错误分类** + +在 `transport/server.go` 标准库 import 组增加: + +```go +"errors" +``` + +将 WSS `Serve` 返回处理替换为: + +```go + err = server.Serve(tls.NewListener(s.streamListener, config)) + if err != nil && !errors.Is(err, http.ErrServerClosed) && !s.IsClosed() { + log.Errorf("http.server.Serve(addr{%s}) = err:%+v", s.addr, perrors.WithStack(err)) + } +``` + +删除 `panic(err)`。不修改证书加载错误,因为它们发生在启动配置阶段,不属于正常关闭问题。 + +- [x] **步骤 4:运行 WSS 测试,确认绿灯** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./transport -run '^TestWSSServerCloseDoesNotPanic$' -count=1" +``` + +预期:PASS,且没有 panic 或错误日志。 + +- [x] **步骤 5:检查任务 1 的变更边界** + +运行: + +```powershell +git diff --check +git diff -- transport/server.go transport/server_test.go +git status --short +``` + +预期:只出现 WSS 测试和错误分类所需变更,以及已批准的规格/计划文件;不 commit。 + +### 任务 2:UDP buffer 边界测试与最小修复 + +**文件:** +- 修改:`transport/session_test.go:29-33` +- 修改:`transport/session.go:48-64` +- 修改:`transport/session.go:914-937` + +- [x] **步骤 1:编写失败的 UDP buffer 表驱动测试** + +在 `transport/session_test.go` 的包级测试辅助类型之前加入: + +```go +func TestUDPReadBufferSize(t *testing.T) { + tests := []struct { + name string + maxMsgLen int32 + want int + }{ + {name: "tiny message", maxMsgLen: 1, want: 2}, + {name: "below crossover", maxMsgLen: maxReadBufLen - 1, want: 2 * (maxReadBufLen - 1)}, + {name: "at crossover", maxMsgLen: maxReadBufLen, want: 2 * maxReadBufLen}, + {name: "above crossover", maxMsgLen: maxReadBufLen + 1, want: 2*maxReadBufLen + 1}, + {name: "large message", maxMsgLen: 128 * 1024, want: 128*1024 + maxReadBufLen}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := udpReadBufferSize(tt.maxMsgLen); got != tt.want { + t.Fatalf("udpReadBufferSize(%d) = %d, want %d", tt.maxMsgLen, got, tt.want) + } + }) + } +} +``` + +一个表驱动测试覆盖同一计算规则的五个输入变体,避免重复测试体。 + +- [x] **步骤 2:运行测试,确认红灯来自 helper 缺失** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./transport -run '^TestUDPReadBufferSize$' -count=1" +``` + +预期:FAIL,编译错误包含 `undefined: udpReadBufferSize`。这证明生产 helper 尚不存在。 + +- [x] **步骤 3:实现最小 UDP buffer 计算并替换错误分支** + +在 `transport/session.go` 常量块后加入: + +```go +func udpReadBufferSize(maxMsgLen int32) int { + maxBufLen := int(maxMsgLen + maxReadBufLen) + if doubledMaxMsgLen := int(maxMsgLen << 1); doubledMaxMsgLen < maxBufLen { + return doubledMaxMsgLen + } + return maxBufLen +} +``` + +在 `handleUDPPackage` 中删除局部变量 `maxBufLen`,并将: + +```go + maxBufLen = int(s.maxMsgLen + maxReadBufLen) + if int(s.maxMsgLen<<1) < bufLen { + maxBufLen = int(s.maxMsgLen << 1) + } + bufp = gxbytes.AcquireBytes(maxBufLen) +``` + +替换为: + +```go + bufp = gxbytes.AcquireBytes(udpReadBufferSize(s.maxMsgLen)) +``` + +- [x] **步骤 4:运行 UDP 测试,确认绿灯** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./transport -run '^TestUDPReadBufferSize$' -count=1" +``` + +预期:PASS,五个子测试全部通过。 + +- [x] **步骤 5:运行两个回归测试的普通与 race 版本** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize)$' -count=1 && go test -race ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize)$' -count=1" +``` + +预期:两个命令均 PASS,race detector 不报告竞态。 + +- [x] **步骤 6:检查任务 2 的变更边界** + +运行: + +```powershell +git diff --check +git diff -- transport/session.go transport/session_test.go +git status --short +``` + +预期:只出现 UDP helper、调用替换和表驱动测试;不 commit。 + +### 任务 3:测试质量门禁与完整验证 + +**文件:** +- 审查:`transport/server_test.go` +- 审查:`transport/session_test.go` +- 验证:全部已修改文件 + +- [x] **步骤 1:按 test-guard 审查新测试** + +逐项确认: + +- WSS 测试断言真实可观察行为,没有 mock 内部 helper。 +- WSS 测试只覆盖正常关闭场景,并明确对应 Issue #97。 +- UDP 的五个输入变体合并在一个表驱动测试中。 +- 测试名称描述场景和期望,不测试 Go/http 框架自身保证。 +- 没有仅为测试向生产类型添加公开方法。 + +若发现违反规则,先修改测试并重新运行对应红绿验证。 + +- [x] **步骤 2:运行 transport race 测试** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test -race ./transport -count=1" +``` + +预期:PASS,无 data race。 + +- [x] **步骤 3:运行静态检查** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go vet ./..." +``` + +预期:退出码 0,无 vet 诊断。 + +- [x] **步骤 4:运行全仓测试** + +运行: + +```bash +wsl.exe --cd /mnt/d/test/github/review/getty-issue-97-fix -- bash -lc \ + "go test ./... -count=1" +``` + +预期:所有有测试的包 PASS,无失败包。 + +- [x] **步骤 5:最终 diff、格式和状态检查** + +运行: + +```powershell +git diff --check +git status --short --branch +git diff --stat +git diff -- transport/server.go transport/server_test.go transport/session.go transport/session_test.go +``` + +预期:无空白错误;生产代码和测试只覆盖原始批准范围。 + +### 任务 4:2026-08-17 review follow-up 测试红灯 + +**文件:** +- 修改:`transport/server_test.go` +- 修改:`transport/session_test.go` + +- [ ] **步骤 1:用真实 TLS 握手替换 WSS 字段发布屏障** + +读取 `server.crt`,加入测试 Root CA,并在 `server.RunEventLoop` 返回后执行: + +```go +certPEM, err := os.ReadFile(certPath) +if err != nil { + t.Fatal(err) +} +roots := x509.NewCertPool() +if !roots.AppendCertsFromPEM(certPEM) { + t.Fatal("failed to add WSS test certificate to root pool") +} + +conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", server.Listener().Addr().String(), &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, +}) +if err != nil { + server.Close() + t.Fatalf("WSS TLS handshake failed: %v", err) +} +if err := conn.Close(); err != nil { + t.Fatal(err) +} +``` + +删除轮询 `server.server != nil` 的 readiness loop。真实 TLS 握手成功才允许测试调用 `server.Close()`。 + +- [ ] **步骤 2:增加 UDP 非正值、极值和生产调用链测试** + +将 `TestUDPReadBufferSize` 的大消息预期改为 `maxUDPReadBufferSize`,并增加 `0`、`-1`、`math.MaxInt32`。增加一个 Reader,把收到的切片长度写入有缓冲 channel;真实 UDP 测试使用 `maxMsgLen=1`、发送 3 字节,并断言 Reader 收到 `udpReadBufferSize(1)` 即 2 字节: + +```go +type udpReadSizeReader struct { + readLen chan int +} + +func (r *udpReadSizeReader) Read(_ Session, data []byte) (any, int, error) { + r.readLen <- len(data) + return nil, 0, errTestReadFailure +} +``` + +测试必须启动真实 `handleUDPPackage`,在断言后关闭 UDP listener,并有界等待 handler 返回;不向生产代码添加测试 hook。 + +- [ ] **步骤 3:运行边界测试并确认红灯原因** + +运行: + +```bash +go test ./transport -run '^(TestUDPReadBufferSize|TestHandleUDPPackageUsesConfiguredReadBuffer)$' -count=1 +``` + +预期:`TestUDPReadBufferSize` 因 `udpReadBufferSize(0)` 返回 0、负值或极值溢出而 FAIL;真实生产路径子测试可以 PASS。失败必须来自缺失边界行为,不得来自测试夹具、端口或超时。 + +### 任务 5:UDP 最小修复与测试绿灯 + +**文件:** +- 修改:`transport/session.go` +- 测试:`transport/session_test.go` + +- [ ] **步骤 1:实现安全、有界的 UDP buffer 计算** + +在常量块增加 `maxUDPReadBufferSize = 64 * 1024`,并将 helper 改为: + +```go +func udpReadBufferSize(maxMsgLen int32) int { + if maxMsgLen <= 0 { + return maxUDPReadBufferSize + } + + bufferSize := int64(maxMsgLen) + int64(maxReadBufLen) + if doubledMaxMsgLen := int64(maxMsgLen) * 2; doubledMaxMsgLen < bufferSize { + bufferSize = doubledMaxMsgLen + } + if bufferSize > maxUDPReadBufferSize { + return maxUDPReadBufferSize + } + return int(bufferSize) +} +``` + +`SetMaxMsgLen` 将 `length <= 0` 保存为 0,将超过 `math.MaxInt32` 的正数保存为 `math.MaxInt32`,其余值按现有 `int32` 字段保存。公开方法签名不变。 + +- [ ] **步骤 2:运行目标测试确认绿灯** + +运行: + +```bash +go test ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize|TestSetMaxMsgLenNormalizesLimits|TestHandleUDPPackageUsesConfiguredReadBuffer)$' -count=1 +``` + +预期:四个测试 PASS,WSS 测试完成真实 TLS 握手,UDP handler 在关闭 listener 后有界返回。 + +- [ ] **步骤 3:验证两个回归测试能杀死对应变异** + +先将 `runWSSEventLoop` 的错误分类临时替换为 `if err != nil { panic(err) }`,运行 `TestWSSServerCloseDoesNotPanic`,预期出现 `panic: http: Server closed`;立即恢复文件。 + +再将 `handleUDPPackage` 的分配临时恢复为旧逻辑: + +```go +maxBufLen := int(s.maxMsgLen + maxReadBufLen) +if int(s.maxMsgLen<<1) < bufLen { + maxBufLen = int(s.maxMsgLen << 1) +} +bufp = gxbytes.AcquireBytes(maxBufLen) +``` + +运行 `TestHandleUDPPackageUsesConfiguredReadBuffer`,预期收到 3 字节而不是 2 字节并 FAIL;立即恢复文件。恢复后重跑步骤 2 中相同的四个目标测试并要求 PASS。 + +### 任务 6:review follow-up 完整验证与发布 + +**文件:** +- 验证:全部修改文件 +- GitHub:PR #108 当前 Head、检查和八个原 review 线程 + +- [ ] **步骤 1:运行格式、race、静态和全仓门禁** + +```bash +gofmt -w transport/server.go transport/server_test.go transport/session.go transport/session_test.go +git diff --check +go test -race ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize|TestSetMaxMsgLenNormalizesLimits|TestHandleUDPPackageUsesConfiguredReadBuffer)$' -count=20 +go test -race ./transport -count=1 +go vet ./... +go test ./... -count=1 +``` + +每条命令必须读取实际退出码和输出;Windows Go 1.26.2 的既有 `TestTCPClient` 基线失败单独记录,不修改该无关测试。 + +- [ ] **步骤 2:检查范围并 commit** + +检查 `git status --short`、`git diff --stat`、完整 diff 和 `git diff --check`。只暂存两份文档、`transport/server.go`、`transport/server_test.go`、`transport/session.go`、`transport/session_test.go`,使用符合本地 Lore hook 的叙述式 commit message、Signed-off-by 和 `Co-authored-by: OmX `。 + +- [ ] **步骤 3:推送并复核最终 Head** + +推送 `codex/fix-issue-97-remaining`,重新获取 PR 的 `headRefOid`、完整检查、review decision、顶层评论和所有 review threads。Head 必须等于本地提交,检查失败或新反馈不得被旧证据覆盖。 + +- [ ] **步骤 4:在原线程回复并核对状态** + +使用 `repos/AlexStocks/getty/pulls/108/comments/{id}/replies` 回复对应行内线程,说明具体修复和验证;同根因线程分别回复但不新建重复顶层评论。回复后重新获取 `isResolved`、`isOutdated`;不代替 reviewer Resolve,也不 merge PR。 diff --git a/doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md b/doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md new file mode 100644 index 00000000..d69950ae --- /dev/null +++ b/doc/superpowers/specs/2026-08-01-github-ci-hardening-design.md @@ -0,0 +1,376 @@ +# GitHub CI 全面加固设计 + +## 背景与证据 + +本设计以 `AlexStocks/getty` PR #108 的实时快照为基准: + +- Base:`master@cc9909dc9e0aab1307f553bc2a3d8400161be4e2` +- Head:`codex/fix-issue-97-remaining@087714342a09f1cc2318bee9d570c2b6ed028044` +- 现有 workflow:`.github/workflows/github-actions.yml` +- 对照仓库:`apache/dubbo-go@53d81d17c0f658b7151fb8c44f0d80371ef047e7` + +PR #108 的 CI 日志确认了以下问题: + +1. `actions/setup-go@v5` 在 checkout 之前执行,内置缓存找不到 `go.sum`;随后 workflow 又使用 `actions/cache@v4` 恢复相同的 Go module/build cache。 +2. Coverage step 执行远程脚本 `bash <(curl -s https://codecov.io/bash)`,Codecov 返回 HTTP 400 和 `Token required - not valid tokenless upload`,但 step 和 job 仍显示成功。 +3. CI 不执行 race detector,无法持续覆盖 Getty 的并发和生命周期风险。 +4. workflow 未显式设置最小权限、并发取消或 job 超时。 +5. `make test` 使用 `go env -w` 修改 runner 用户级 Go 配置;测试也未使用 `-count=1`。 +6. `imports-formatter@latest` 是浮动工具依赖;GitHub Actions 也使用可变 major tag 或 `@main`。 +7. README 仍展示 Travis CI badge,仓库仍保留已不参与当前 PR 检查的 `.travis.yml`;该文件还包含明文 Codecov upload token 和第三方 webhook access token。 +8. `master` 分支当前没有 required status checks,也没有 repository ruleset。 + +## 目标 + +本次改造采用完整方案,目标是: + +1. 让测试、race、格式、静态检查、coverage 上传失败能够真实反映到 GitHub check 结果。 +2. 消除重复缓存、全局 Go 配置写入和浮动工具版本。 +3. 将 workflow 权限限制在每个 job 实际需要的最小集合。 +4. 增加跨平台构建、CodeQL 和 Dependabot,覆盖 Go 源码、GitHub Actions 与依赖维护。 +5. 固定第三方 Action 到核验过的完整 commit SHA,并在受审查文档中保留版本与 SHA 映射,兼顾供应链可审计性和后续升级。 +6. 清理已经被 GitHub Actions 取代的 Travis CI 展示与配置。 +7. 保持 Getty 公开 Go API 和运行时行为不变。 + +## 非目标与权限边界 + +- 不在本次 CI 改造中修复 PR #108 已审查出的 UDP 运行时问题或测试缺口;这些问题继续由现有 Files changed 线程跟踪。 +- 不增加 Getty 专属外部集成服务、数据库、消息队列或部署流程。 +- 不在 workflow 中自动发布、创建 release、写回源码或提交生成文件。 +- 不直接修改 GitHub branch protection 或 ruleset。required checks 属于 PR 文件之外的仓库设置;只有新 job 名称和实际运行结果稳定后,才提交精确配置建议,并在获得单独确认后修改。 +- 不调用或验证 `.travis.yml` 中暴露的第三方 token。删除文件不能从 Git 历史撤销凭据;轮换或吊销 Codecov/DingTalk 凭据属于需要账号权限的外部安全收尾。 +- 不把 Dubbo-Go 的 RPC integration test、RISC-V 工具子模块或 samples 流程机械复制到 Getty。 + +## 变更文件 + +### 修改 + +- `.github/workflows/github-actions.yml` +- `Makefile` +- `README.md` +- `README_CN.md` + +### 新增 + +- `.github/workflows/codeql.yml` +- `.github/dependabot.yml` + +### 删除 + +- `.travis.yml` + +删除 `.travis.yml` 的前提是最终复核仍满足:PR status rollup 中没有 Travis check,GitHub Actions 已覆盖其有效命令,README badge 同步改为 GitHub Actions。 + +## 设计 + +### 1. 主 CI workflow + +保留 `.github/workflows/github-actions.yml` 作为主 workflow,名称继续使用 `CI`,触发范围为: + +- push 到 `master` +- 以 `master` 为 base 的 pull request + +workflow 顶层设置: + +```yaml +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +``` + +`concurrency` 用于取消同一 PR 或同一 ref 的旧运行,避免过期 Head 继续占用 runner。主 CI 最终包含 5 个逻辑 job:`Check License Header`、`Test and Lint`、`Upload Coverage`、`Race` 和 `Build` matrix。所有 job 都设置显式 `timeout-minutes`,防止网络测试、工具下载或 race 测试无限挂起。 + +### 2. Action 固定策略 + +所有第三方 Action 使用完整 commit SHA。版本与 SHA 的对应关系必须在设计或计划中集中记录;workflow 同行可以保留版本注释,但不得用可变 tag 替代 SHA,例如: + +```yaml +uses: actions/checkout@ # v7 +with: + persist-credentials: false +``` + +实现前重新查询并固定: + +- `actions/checkout@v7` +- `actions/setup-go@v7.0.0` +- `apache/skywalking-eyes/header` 当前核验的 official main verified commit +- `actions/upload-artifact@v7.0.1` +- `actions/download-artifact@v8.0.1` +- `codecov/codecov-action@v7` +- `github/codeql-action@v3` + +Dependabot 的 `github-actions` ecosystem 负责后续 Action 更新。不得使用 `@main`,也不得在同一 workflow 中同时保留 major tag 与完整 SHA 两套引用方式。 + +所有 5 个 checkout step 都显式设置 `persist-credentials: false`。这些 job 在 checkout 后只执行本地源码读取、Go 构建/测试、artifact 操作和固定 SHA 的 Action,不执行需要仓库认证的 `git fetch`、`git push`、remote 或 submodule 操作,因此不应把 checkout token 或 SSH key 持久化到本地 Git 配置。CodeQL 的初始化和结果上传使用 `github/codeql-action` 自身的 `token` 输入,默认值为 `${{ github.token }}`,不依赖 checkout 写入的 Git credential;这项加固不改变既有 `permissions`。 + +本轮正式质量审查确认:`actions/upload-artifact@v7.0.1` 的 release 与 tag ref 都指向 `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a`;`actions/download-artifact@v8.0.1` 的 release 与 tag ref 都指向 `3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c`。`download-artifact` v8.0.1 tag 下 README 仍有一处 `@v7` 示例,属于示例文本滞后;release 元数据和精确 tag ref 一致,因此实现以 release/ref 指向的完整 SHA 为准,不因 README 的单处旧示例降级到 v7。 + +Action 固定默认采用官方稳定 release/tag 对应的完整 SHA。仅当官方 main 上的 verified commit 明确晚于最新 release,且退回该 release 会撤销安全加固或可复现性改进时,才允许在设计中记录 provenance 后固定该 verified commit;这个例外不允许使用 `@main` 等可变引用。本轮 `actions/setup-go@v7.0.0` 的 release、`v7.0.0` 与 `v7` tag 均指向 `b7ad1dad31e06c5925ef5d2fc7ad053ef454303e`,可直接替换 v6.5.0 SHA,现有输入和 Node 24 runner 要求不变。`apache/skywalking-eyes/header@315732dd4b8d3a015d8d9b91936b935a0b854817` 是 official main 上经 GitHub 验证、比 v0.8.0 release commit 多 27 个提交的固定提交;它已将内部 `setup-go` 固定到完整 SHA,并对 shell 输入进行环境变量和引用加固,因此保留该提交,避免降级到 v0.8.0。 + +### 3. License job + +License job: + +- `permissions: contents: read` +- checkout 固定到完整 SHA,并设置 `persist-credentials: false` +- SkyWalking Eyes 固定到完整 SHA +- `timeout-minutes: 10` +- 保持 `.licenserc.yaml` 和 `mode: check` + +License job 不获得 `id-token`、`security-events` 或写入仓库内容的权限。 + +### 4. Test and Lint job + +主验证 job 使用稳定名称 `Test and Lint`,步骤顺序固定为: + +1. Checkout +2. Setup Go +3. Verify modules +4. Check format +5. Unit tests and coverage +6. Lint +7. Upload coverage artifact + +Setup Go 使用: + +```yaml +with: + go-version-file: go.mod + cache-dependency-path: go.sum +``` + +删除独立 `actions/cache` step,让 `setup-go` 成为 Go module/build cache 的唯一 owner。 + +Checkout 显式禁用 credential persistence;后续 module 验证、格式检查、测试、lint 和 artifact 上传都不执行需要 Git remote 认证的命令。 + +模块验证执行 `go mod verify`。格式检查执行 `make check-fmt`。测试执行 `make test`,生成 `coverage.txt`。Lint 执行 `make lint`。随后使用固定 SHA 的 `actions/upload-artifact@v7.0.1` 上传 artifact:名称为 `coverage`,路径为 `coverage.txt`,文件缺失时报错,保留 1 天。 + +`Test and Lint` 继承 workflow 顶层的 `contents: read`,不声明也不获得 `id-token: write`。OIDC 权限只授予后续隔离的 `Upload Coverage` job。 + +### 5. Coverage artifact 与隔离的 Codecov OIDC + +新增 `Upload Coverage` job,`needs: test-and-lint`。它不 checkout 源码、不 setup Go,也不执行 shell 命令;只下载前一 job 产生的 `coverage` artifact,再调用固定到完整 SHA 的 `codecov/codecov-action`。这样只有 coverage 上传边界获得 OIDC 权限,不再下载并执行 Codecov bash uploader。 + +`Upload Coverage` job 的权限只有: + +```yaml +permissions: + id-token: write +``` + +artifact 下载固定为: + +```yaml +- name: Download Coverage Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: coverage +``` + +Codecov 参数至少包含: + +```yaml +with: + version: v11.3.1 + use_oidc: true + fail_ci_if_error: true + files: ./coverage.txt + disable_search: true +``` + +设计要求: + +- 上传失败必须使 job 失败。 +- 不依赖 `CODECOV_TOKEN` 仓库 secret。 +- 只上传明确生成的 `coverage.txt`,不扫描工作区中的其他 coverage 文件。 +- `id-token: write` 只能出现在 `Upload Coverage` job;`Test and Lint`、License、Race 和 Build 均不得获得 OIDC。 +- push 后必须核对日志中没有 HTTP 400、tokenless upload 错误或被吞掉的非零状态。 + +### 6. Race job + +新增独立 job `Race`: + +- Ubuntu runner +- checkout + setup-go 内置缓存 +- `timeout-minutes: 15` +- 执行 `make test-race` + +`make test-race` 固定执行: + +```bash +GOTOOLCHAIN=go1.25.0+auto go test -race ./transport -count=1 +``` + +Race job 与普通单测并行,单独展示结果,便于后续配置 required check。 + +### 7. 跨平台构建 job + +新增 job `Build`,使用真实 GitHub-hosted runner matrix: + +- `ubuntu-latest` +- `windows-latest` +- `macos-latest` + +每个平台执行 checkout、setup-go、`go mod verify` 和 `go build ./...`。该 job 只验证编译兼容性,不在本次范围内把全量网络测试扩展到 Windows/macOS,避免把既有平台测试差异和 CI 架构改造混为一体。 + +Matrix 设置 `fail-fast: false`,保证一个平台失败时仍能取得另外两个平台的完整证据。job 名称包含 runner OS,便于将来精确配置 required checks。 + +### 8. Makefile + +Makefile 调整为可由本地和 CI 复用的显式门禁: + +- `.PHONY` 补全 `check-fmt`、`test-race` 和安装目标。 +- `test` 不再执行 `go env -w`,改为命令级 `GOTOOLCHAIN`。 +- `test` 增加 `-count=1`,同时保留 atomic coverage 输出。 +- 新增 `test-race`,只运行 `./transport` 的 race 测试。 +- 新增 `check-fmt`:先用普通命令把 `git ls-files -z` 和 `git ls-files -z -- '*.go'` 的结果分别写入 `mktemp` 临时镜像 `.git/` 目录下的 NUL 清单,确保 shell 的 `set -e` 能直接观察任一枚举失败;循环只从已成功生成的清单文件读取。随后把当前 tracked 工作树内容复制到临时镜像,只在临时镜像内依次执行命令级 `GOTOOLCHAIN=go1.25.0+auto go fmt ./...` 和固定的 `imports-formatter v1.0.10`。最后逐个 tracked Go 文件使用 `git hash-object --path=<原路径>` 计算应用仓库 clean filter 后的对象哈希,比较当前文件与临时格式化结果;不一致时列出文件并非零退出。 +- `imports-formatter` 从 `@latest` 固定到本次已验证的 `v1.0.10`。 +- `golangci-lint` 暂时保持当前已验证的 `v2.4.0`,避免在 CI 架构改造中混入新 lint 规则导致的源码修复;升级到 Dubbo-Go 使用的更高版本应单独处理。 + +`fmt` 保留为开发者明确调用的写入式格式化目标;`check-fmt` 不依赖 `fmt`,也不对当前 checkout 中的 tracked 文件运行写入式 formatter。临时目录通过 `trap` 清理,文件枚举、复制、格式化或 formatter 任一步失败都必须非零传播;不得把 `git ls-files` 放在 process substitution 中,因为消费循环的成功状态会隐藏生产者的非零退出。清单位于仓库外的临时 `.git/` 目录,不会被复制或参与格式比较。比较使用带原路径的 Git clean-filter 哈希而不是原始字节比较,因此 `core.autocrlf=true` 下语义相同的 CRLF 工作树文件与 LF 格式化结果不会误报;无论检查成功还是失败,当前文件的物理换行和字节内容都保持不变。 + +### 9. CodeQL + +新增 `.github/workflows/codeql.yml`: + +- push 到 `master` +- 以 `master` 为 base 的 pull request +- 每周一次定时扫描 +- `concurrency` 取消同一 PR 的旧扫描 + +权限限定为: + +```yaml +permissions: + contents: read + +jobs: + analyze: + permissions: + actions: read + contents: read + security-events: write +``` + +CodeQL 显式指定 `languages: go`,使用固定 commit SHA 的 `init` 和 `analyze`。其 checkout 同样设置 `persist-credentials: false`;`init` 和 `analyze` 使用 Action 自身默认的 `${{ github.token }}` 输入完成包访问和结果上传,不依赖本地 Git credential。当前官方形状是在 `init` 中设置 `build-mode: autobuild`,随后直接执行 `analyze`,不再增加显式 `github/codeql-action/autobuild` step。旧的 `init(build-mode: autobuild) -> autobuild -> analyze` 三步形状仍可兼容运行,但显式 `autobuild` 与 init 的 build mode 重复,属于冗余。不得复制 Dubbo-Go workflow 中手工 checkout PR merge commit 父节点的历史逻辑;使用 GitHub 当前标准 pull request checkout 语义。 + +### 10. Dependabot + +新增 `.github/dependabot.yml`: + +- `gomod`:根目录,每周更新,目标分支 `master` +- `github-actions`:根目录,每月更新,目标分支 `master` +- `gomod` 的 `open-pull-requests-limit` 设为 `5`,commit message 前缀设为 `deps` +- `github-actions` 的 `open-pull-requests-limit` 设为 `3`,commit message 前缀设为 `ci` + +配置只负责创建依赖更新 PR,不自动 approve、merge 或修改 branch protection。 + +PR 分支阶段只能通过严格 YAML 解析和字段/结构断言验证该文件;GitHub 是否接受并启用 Dependabot、是否按计划创建更新 PR,只能在配置合并到默认分支 `master` 后确认。PR push 后没有可证明“已接受/已启用/已排程”的 GitHub 结果,不得把本地结构验证写成平台接受证据。 + +### 11. README 与 Travis 清理 + +`README.md` 和 `README_CN.md` 的 Travis badge 替换为 GitHub Actions `CI` workflow badge,并继续保留 Codecov、Go reference、Go Report Card 和 license badge。 + +确认当前 GitHub status rollup 没有 Travis check 后删除 `.travis.yml`。删除前对照其命令与新 workflow,确保 Go 测试、race、格式、lint、coverage 和构建范围不存在仅由 Travis 承担的路径。 + +旧 Travis 文件中的明文 Codecov token 和第三方 webhook token 已经进入 Git 历史。PR 负责从当前树删除这些值,并在收尾报告中列出必须由仓库维护者完成的轮换/吊销动作;不得在评论、日志、设计文档或 commit message 中复制 token 内容。 + +### 12. Branch protection 后续配置 + +本 PR 只提交可审查的仓库文件。新 workflow 在 PR #108 当前 Head 上全部稳定通过后,输出建议 required checks 列表,预计包含: + +- `Check License Header` +- `Test and Lint` +- `Upload Coverage` +- `Race` +- 三个平台的 `Build` matrix checks +- `Analyze (Go)`(CodeQL workflow 的真实 check 名预计值) + +实际 check 名称以 GitHub 新运行返回值为准。修改 branch protection/ruleset 前必须再次获取现有配置,使用增量更新,保留 force-push、review、conversation resolution 等与本任务无关的设置,并获得单独确认。 + +## 验证策略 + +### 静态与语法验证 + +- `git diff --check` +- 使用 `actionlint v1.7.12` 检查全部 `.github/workflows/*.yml` 与 `*.yaml` +- 解析 `.github/dependabot.yml`,确认 YAML 语法和必需字段 +- 检查所有 `uses:` 都固定为完整 40 字符 SHA +- 确认全部 5 个 checkout step 都显式设置 `persist-credentials: false`,不存在 `true` 或缺失项,并扫描后续 step 不含需要 Git remote credential 的操作 +- 确认当前两个 workflow 共 6 个逻辑 job、14 个 `uses:`;其中主 CI 为 5 个逻辑 job +- 确认 `id-token: write` 只出现 1 次且位于 `Upload Coverage`,`Test and Lint` 无 OIDC +- 确认 Codecov `version` 固定为 `v11.3.1` +- 检查不存在 `@main`、`@latest`、`curl | bash` 或 process substitution 远程执行 + +### Makefile 验证 + +在主验证副本和 `probes/` 下的独立变异副本运行: + +- `make check-fmt` +- `make test` +- `make test-race` +- `make lint` +- `git status --porcelain=v2 --branch --untracked-files=all` + +确认 `make test` 不改写用户级 `go env`,工具版本与设计一致。`check-fmt` 必须完成以下 TDD 门禁:LF clean 输入退出 0 且 tracked Go 文件聚合字节哈希和 Git 状态前后不变;`core.autocrlf=true` 的 clean CRLF 输入退出 0 且物理 EOL、字节哈希和 clean 状态不变;仅 gofmt 差异退出非零;仅项目 import order 差异在 `gofmt -d` 为空时仍退出非零;formatter 故障非零传播且临时目录被清理;第一个 tracked-file 枚举在部分或全部输出后返回 38 时目标非零且 formatter 不执行;第二个 Go-file 枚举零输出后返回 37 时目标仍非零。所有失败探针都必须证明被测源文件的字节哈希和 Git 状态前后不变,并确认没有临时目录残留。 + +### Go 验证 + +- `go mod verify` +- `go vet ./...` +- `go test ./... -count=1` +- `go test -race ./transport -count=1` +- `GOOS=windows GOARCH=amd64 go build ./...` +- `GOOS=darwin GOARCH=amd64 go build ./...` +- `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build ./...` +- `GOOS=linux GOARCH=riscv64 CGO_ENABLED=0 go build ./...` + +本地交叉编译不能替代真实 GitHub Windows/macOS runner;最终结论必须等待远端 matrix job。 + +### GitHub 实时验证 + +push 前: + +1. 重新获取 PR state、Base、Head、checks 和远端 head branch SHA。 +2. 确认远端 Head 仍等于本轮实现基准,不使用 force push。 +3. commit 后再次核对本地 branch 只领先预期提交。 + +push 后: + +1. 等待全部新 workflow run 完成。 +2. 核对每个 job 的真实命令、平台、结论和日志。 +3. 确认 `Test and Lint` 成功上传 `coverage` artifact,`Upload Coverage` 下载同名 artifact 后完成 Codecov 上传,且两段失败都可传播。 +4. 确认 setup-go 在 checkout 后找到 `go.sum`,不存在第二套 Go cache。 +5. 确认 CodeQL 上传 security result 成功。 +6. 对 Dependabot 只确认 PR 内 YAML 和结构门禁通过;实际接受、启用和排程列为合并到默认分支后的验证项。 +7. 重新获取 PR Base/Head、mergeable、mergeStateStatus、reviewDecision、required checks 和 review threads。 + +## 失败处理与回滚 + +- actionlint/YAML 失败:只修 workflow 语法,不绕过检查。 +- Windows/macOS build 暴露既有源码不兼容:记录为独立产品问题;不为了让 CI 变绿而跳过失败包。若修复明显超出 CI 范围,保留失败证据并由用户决定拆分或扩大授权。 +- Codecov OIDC 不被当前仓库接受:先核对 job 权限和 Codecov 官方日志;不得恢复旧 bash uploader。若需要 Codecov 侧启用设置,报告精确外部前置条件。 +- CodeQL 因仓库安全设置不可用:保留 workflow 和失败证据,说明所需 GitHub 设置;不把环境/权限失败归责为 Go 源码问题。 +- 远端 Head 漂移:停止 push,重新审查新增远端提交并适配;禁止 force push 覆盖。 +- 回滚通过新增普通 commit 完成,不改写 PR 历史。 + +## 完成标准 + +只有同时满足以下条件,CI 改造才算完成: + +1. 本设计列出的仓库文件完成修改,且没有越过非目标边界。 +2. 本地 workflow、YAML、Makefile、Go 测试、race、lint 和交叉编译验证获得新鲜证据。 +3. 新提交以普通 push 进入 PR #108,不覆盖远端新增提交。 +4. GitHub 上 License、Test and Lint、Upload Coverage、Race、Build matrix、CodeQL 全部产生可识别的 checks。 +5. Codecov Action 使用 CLI `v11.3.1`,上传成功,日志不再出现 HTTP 400 或被吞掉的失败。 +6. setup-go 缓存由唯一 action 管理,并在 checkout 后读取 `go.sum`。 +7. README badge 指向 GitHub Actions,旧 Travis 配置已在覆盖核对后删除。 +8. Dependabot 配置在 PR 内通过严格 YAML 和结构验证;合并到默认分支后另行确认 GitHub 接受、启用和排程,不把该外部结果作为 PR push 阶段可完成的条件。 +9. 最终 Head 与验证基准一致。 +10. PR #108 的 UDP P1 finding 仍单独对账,不因 CI 改造而被误报为已修复。 +11. 收尾报告明确要求轮换或吊销旧 Travis 文件中暴露的 Codecov 和第三方 webhook 凭据,并确认 PR 没有再次复制其值。 +12. 静态政策断言与最终文件一致:主 CI 5 个逻辑 job、全部 workflow 合计 6 个逻辑 job 与 14 个 `uses:`,OIDC 只授予 `Upload Coverage`;全部 5 个 checkout step 均显式禁用 credential persistence,且后续 step 不依赖 Git remote 认证。 diff --git a/doc/superpowers/specs/2026-08-01-issue-97-remaining-runtime-fixes-design.md b/doc/superpowers/specs/2026-08-01-issue-97-remaining-runtime-fixes-design.md new file mode 100644 index 00000000..a4731812 --- /dev/null +++ b/doc/superpowers/specs/2026-08-01-issue-97-remaining-runtime-fixes-design.md @@ -0,0 +1,122 @@ +# Issue #97 剩余确定性运行时问题修复设计 + +## 目标 + +修复 Issue #97 在当前 `master@cc9909dc9e0aab1307f553bc2a3d8400161be4e2` 上仍可由源码直接确认的两个确定性问题: + +1. WSS 服务在正常关闭时,将 `http.Server.Serve` 返回的预期关闭错误升级为进程级 panic。 +2. UDP 接收缓冲区大小计算使用尚未由 `recv` 填充的 `bufLen`,导致分支恒定按零值判断。 + +变更必须保持现有公开接口不变,并通过 Linux race 测试、静态检查和仓库测试验证。 + +## 范围 + +### 本批包含 + +- 修改 `transport/server.go` 的 WSS `Serve` 返回处理。 +- 修改 `transport/session.go` 的 UDP 接收缓冲区大小计算。 +- 在 `transport/server_test.go` 增加 WSS 正常启动和关闭的回归测试。 +- 在 `transport/session_test.go` 增加 UDP 缓冲区大小边界测试和真实 UDP 接收路径测试。 + +### 本批不包含 + +- 不修改 `WithReconnectAttempts` 的语义。当前公开文档将其描述为最大重连尝试次数,现有测试也验证总尝试次数;把它改为连续失败次数需要单独设计和兼容性决策。 +- 不修改 Issue #93 的 Bug 模板、自动回复或 Release Note workflow。 +- 不重构 WS/WSS 服务生命周期之外的代码。 +- 2026-08-17 review follow-up 已授权 commit 并 push 到现有 PR #108 分支;不 merge,也不修改或关闭 GitHub Issue。 + +## 设计 + +### 1. WSS 正常关闭不再 panic + +当前 WSS event loop 对 `server.Serve(tls.NewListener(...))` 的任意非空错误执行 `panic(err)`。`http.Server.Serve` 在调用 `Shutdown` 或 `Close` 后会返回 `http.ErrServerClosed`,这是服务生命周期的正常结束信号。 + +修改后的行为: + +- `errors.Is(err, http.ErrServerClosed)` 时直接退出 goroutine,不记录错误,不 panic。 +- Server 已进入 Getty 自身关闭状态时,listener close 产生的返回同样作为预期退出处理。 +- 其他 `Serve` 错误沿用非 TLS WS event loop 的容错方式:记录带地址和错误上下文的错误日志,然后退出 goroutine,不在后台服务 goroutine 中 panic 整个进程。 +- 保留 `defer s.wg.Done()`,确保 `Server.Close()` 能完成等待。 + +不引入新的公开 API。错误分类逻辑优先保持在 `runWSSEventLoop` 附近,除非测试表明抽取小型私有 helper 能显著降低重复。 + +### 2. UDP 接收缓冲区大小使用目标变量计算 + +当前意图等价于在两个上限中取较小值: + +```text +min(maxMsgLen + maxReadBufLen, 2 * maxMsgLen) +``` + +现有实现错误地将尚未赋值的 `bufLen` 与 `2 * maxMsgLen` 比较。修复将计算提取为包内私有函数: + +```go +func udpReadBufferSize(maxMsgLen int32) int +``` + +函数规则: + +- `maxMsgLen <= 0` 继续表示现有的“不限制消息长度”语义,UDP 接收使用 `64 KiB` 的有界回退;该大小足以容纳普通 UDP 数据报,同时不会向 `AcquireBytes` 传递零或负值。 +- 正数输入先在 `int64` 中计算 `min(maxMsgLen + maxReadBufLen, 2 * maxMsgLen)`,避免 `int32` 加法和左移溢出。 +- 计算结果上限为 `64 KiB`。UDP 数据报的长度字段只有 16 位,更大的单次接收分配没有可观察收益,只会扩大内存风险。 +- `SetMaxMsgLen` 将非正值规范化为 `0`,并将超出 `int32` 的正数限制到 `math.MaxInt32`,避免公开 `int` 参数在保存时绕回负数。 +- `handleUDPPackage` 只负责使用返回值申请和释放 buffer,不再保留尚未接收数据就读取 `bufLen` 的分支。 + +提取函数的目的是让边界规则可以直接测试,而不是暴露新的产品接口。 + +## 测试设计 + +### WSS 生命周期测试 + +新增集成回归测试,使用仓库已有 TLS 测试证书或测试内临时证书夹具: + +1. 创建监听随机本地端口的 WSS Server。 +2. 在 goroutine 中启动 `RunEventLoop`。 +3. 使用测试证书作为 Root CA 建立一次真实 TLS 连接;握手成功证明 `http.Server.Serve` 已经接受连接,而不只是 `server.server` 字段已经赋值。 +4. 关闭测试客户端连接,再调用 `Close()`。 +5. 断言 `Close()` 和 event loop 在有界时间内返回。 + +在修复前,测试应因服务 goroutine 执行 `panic(http.ErrServerClosed)` 而失败;修复后应正常通过。测试不得通过 sleep 或字段发布猜测启动状态。review follow-up 通过临时恢复无条件 panic 的变异再次确认测试会变红,然后恢复生产实现。 + +### UDP 边界测试 + +对 `udpReadBufferSize` 使用表驱动测试,至少覆盖: + +| `maxMsgLen` | 预期结果 | 说明 | +|---:|---:|---| +| `1` | `2` | 小消息由 `2 * maxMsgLen` 限制 | +| `4095` | `8190` | 低于交叉点一字节 | +| `4096` | `8192` | 两个公式在交叉点相等 | +| `4097` | `8193` | 高于交叉点后由 `maxMsgLen + 4096` 限制 | +| `0` | `64 * 1024` | 未设置限制时使用完整 UDP 数据报回退 | +| `-1` | `64 * 1024` | 防御负值,不产生负分配 | +| `128 * 1024` | `64 * 1024` | 大配置受 UDP 数据报上限约束 | +| `math.MaxInt32` | `64 * 1024` | 极值不发生 `int32` 溢出或超大分配 | + +另增加真实 `net.UDPConn`、`session` 和 `handleUDPPackage` 测试。测试发送一个长度大于 helper 结果的数据报,并由真实 Reader 记录收到的切片长度。把生产分配临时恢复为旧的 `bufLen` 死分支时,该测试必须失败;恢复 helper 调用后必须通过。这条变异验证保证测试覆盖生产调用链,而不只覆盖纯函数。 + +## 验证 + +实现完成后在 WSL/Linux、Go 1.25.1 下依次运行: + +```bash +go test ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize|TestSetMaxMsgLenNormalizesLimits|TestHandleUDPPackageUsesConfiguredReadBuffer)$' -count=1 +go test -race ./transport -run '^(TestWSSServerCloseDoesNotPanic|TestUDPReadBufferSize|TestSetMaxMsgLenNormalizesLimits|TestHandleUDPPackageUsesConfiguredReadBuffer)$' -count=1 +go test -race ./transport -count=1 +go vet ./... +go test ./... -count=1 +``` + +若仓库级命令因为既有基线、环境或超时失败,必须区分本次新增失败和环境/基线失败,不得通过修改或跳过测试来制造通过结果。 + +## 完成标准 + +- WSS 正常关闭路径不产生 panic,并能完成 WaitGroup 等待。 +- WSS 回归测试通过真实 TLS 握手证明 `Serve` 已经进入服务状态。 +- 非预期 WSS `Serve` 错误仍被记录,不静默吞掉。 +- UDP buffer 计算不再读取接收前的 `bufLen`。 +- UDP buffer 对非正值和 `int32` 极值始终返回正数、有界结果。 +- UDP 生产调用链测试能杀死恢复旧分配逻辑的变异。 +- 新测试经过明确的红灯和绿灯阶段。 +- WSL/Linux race 测试、静态检查和适用的仓库测试获得新鲜验证结果。 +- 用户原始 checkout 和其中的未跟踪内容保持不变。 diff --git a/transport/server.go b/transport/server.go index f5f60808..9f4ca227 100644 --- a/transport/server.go +++ b/transport/server.go @@ -21,6 +21,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "errors" "fmt" "net" "net/http" @@ -530,9 +531,8 @@ func (s *server) runWSSEventLoop(newSession NewSessionCallback) { s.server = server s.lock.Unlock() err = server.Serve(tls.NewListener(s.streamListener, config)) - if err != nil { + if err != nil && !errors.Is(err, http.ErrServerClosed) && !s.IsClosed() { log.Errorf("http.server.Serve(addr{%s}) = err:%+v", s.addr, perrors.WithStack(err)) - panic(err) } }() } diff --git a/transport/server_test.go b/transport/server_test.go index 605981a5..cc791d83 100644 --- a/transport/server_test.go +++ b/transport/server_test.go @@ -21,10 +21,13 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" "errors" "io" "net" "net/http" + "os" "path/filepath" "strings" "testing" @@ -315,6 +318,67 @@ func TestServer(t *testing.T) { testTCPTlsServer(t, addr) } +// Regression test for #97: normal WSS shutdown must not panic on http.ErrServerClosed. +func TestWSSServerCloseDoesNotPanic(t *testing.T) { + certPath, err := filepath.Abs("../examples/profiles/wss/server_cert/server.crt") + if err != nil { + t.Fatal(err) + } + keyPath, err := filepath.Abs("../examples/profiles/wss/server_cert/server.key") + if err != nil { + t.Fatal(err) + } + + server := newServer( + WSS_SERVER, + WithLocalAddress("127.0.0.1:0"), + WithWebsocketServerPath("/ws"), + WithWebsocketServerCert(certPath), + WithWebsocketServerPrivateKey(keyPath), + ) + server.RunEventLoop(func(Session) error { return nil }) + + closeServer := func() { + t.Helper() + closed := make(chan struct{}) + go func() { + server.Close() + close(closed) + }() + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Error("WSS server Close did not return") + } + } + defer func() { + if !server.IsClosed() { + closeServer() + } + }() + + certPEM, err := os.ReadFile(certPath) + if err != nil { + t.Fatal(err) + } + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(certPEM) { + t.Fatal("failed to parse WSS server certificate") + } + clientConn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", server.Listener().Addr().String(), &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + }) + if err != nil { + t.Fatalf("TLS handshake with WSS server failed: %v", err) + } + if err := clientConn.Close(); err != nil { + t.Fatalf("close TLS client connection: %v", err) + } + + closeServer() +} + func TestWSServeWSRequestClosesSelfConnectConn(t *testing.T) { server := newServer(WS_SERVER) newSessionCalled := false diff --git a/transport/session.go b/transport/session.go index ff1634f0..eddddecf 100644 --- a/transport/session.go +++ b/transport/session.go @@ -23,6 +23,7 @@ import ( "crypto/tls" "fmt" "io" + "math" "net" "runtime" "sync" @@ -46,10 +47,11 @@ import ( ) const ( - maxReadBufLen = 4 * 1024 - netIOTimeout = 1e9 // 1s - period = 60 * 1e9 // 1 minute - pendingDuration = 3e9 + maxReadBufLen = 4 * 1024 + maxUDPReadBufferSize = 64 * 1024 + netIOTimeout = 1e9 // 1s + period = 60 * 1e9 // 1 minute + pendingDuration = 3e9 // MaxWheelTimeSpan 900s, 15 minute MaxWheelTimeSpan = 900e9 maxPacketLen = 16 * 1024 @@ -64,6 +66,21 @@ const ( outputFormat = "session %s, Read Bytes: %d, Write Bytes: %d, Read Pkgs: %d, Write Pkgs: %d" ) +func udpReadBufferSize(maxMsgLen int32) int { + if maxMsgLen <= 0 { + return maxUDPReadBufferSize + } + + bufferSize := int64(maxMsgLen) + int64(maxReadBufLen) + if doubledMaxMsgLen := int64(maxMsgLen) * 2; doubledMaxMsgLen < bufferSize { + bufferSize = doubledMaxMsgLen + } + if bufferSize > maxUDPReadBufferSize { + return maxUDPReadBufferSize + } + return int(bufferSize) +} + var defaultTimerWheel *gxtime.TimerWheel func init() { @@ -346,7 +363,14 @@ func (s *session) SetMaxMsgLen(length int) { s.lock.Lock() defer s.lock.Unlock() - s.maxMsgLen = int32(length) + switch { + case length <= 0: + s.maxMsgLen = 0 + case int64(length) > int64(math.MaxInt32): + s.maxMsgLen = math.MaxInt32 + default: + s.maxMsgLen = int32(length) + } } // SetName set session name @@ -914,25 +938,20 @@ func (s *session) handleTCPPackage() error { // get package from udp packet func (s *session) handleUDPPackage() error { var ( - ok bool - err error - netError net.Error - conn *gettyUDPConn - bufLen int - maxBufLen int - bufp *[]byte - buf []byte - addr *net.UDPAddr - pkgLen int - pkg any + ok bool + err error + netError net.Error + conn *gettyUDPConn + bufLen int + bufp *[]byte + buf []byte + addr *net.UDPAddr + pkgLen int + pkg any ) conn = s.Connection.(*gettyUDPConn) - maxBufLen = int(s.maxMsgLen + maxReadBufLen) - if int(s.maxMsgLen<<1) < bufLen { - maxBufLen = int(s.maxMsgLen << 1) - } - bufp = gxbytes.AcquireBytes(maxBufLen) + bufp = gxbytes.AcquireBytes(udpReadBufferSize(s.maxMsgLen)) defer gxbytes.ReleaseBytes(bufp) buf = *bufp for !s.IsClosed() { diff --git a/transport/session_test.go b/transport/session_test.go index c7e5e3f8..25b6b5a6 100644 --- a/transport/session_test.go +++ b/transport/session_test.go @@ -20,7 +20,9 @@ package getty import ( "errors" "io" + "math" "net" + "strconv" "sync" "testing" "time" @@ -31,6 +33,135 @@ var ( errUnexpectedSecondRead = errors.New("unexpected second read") ) +// Regression test for #97: size the UDP read buffer from configured limits, not unread data. +func TestUDPReadBufferSize(t *testing.T) { + tests := []struct { + name string + maxMsgLen int32 + want int + }{ + {name: "tiny message", maxMsgLen: 1, want: 2}, + {name: "below crossover", maxMsgLen: maxReadBufLen - 1, want: 2 * (maxReadBufLen - 1)}, + {name: "at crossover", maxMsgLen: maxReadBufLen, want: 2 * maxReadBufLen}, + {name: "above crossover", maxMsgLen: maxReadBufLen + 1, want: 2*maxReadBufLen + 1}, + {name: "zero message limit", maxMsgLen: 0, want: 64 * 1024}, + {name: "negative message limit", maxMsgLen: -1, want: 64 * 1024}, + {name: "large message", maxMsgLen: 128 * 1024, want: 64 * 1024}, + {name: "maximum message limit", maxMsgLen: math.MaxInt32, want: 64 * 1024}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := udpReadBufferSize(tt.maxMsgLen); got != tt.want { + t.Fatalf("udpReadBufferSize(%d) = %d, want %d", tt.maxMsgLen, got, tt.want) + } + }) + } +} + +func TestSetMaxMsgLenNormalizesLimits(t *testing.T) { + type testCase struct { + name string + length int + want int32 + } + tests := []testCase{ + {name: "negative becomes unlimited", length: -1, want: 0}, + {name: "zero remains unlimited", length: 0, want: 0}, + } + if strconv.IntSize == 64 { + oversized := int64(math.MaxInt32) + 1 + tests = append(tests, testCase{name: "oversized value is clamped", length: int(oversized), want: math.MaxInt32}) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ss := &session{} + ss.SetMaxMsgLen(tt.length) + if ss.maxMsgLen != tt.want { + t.Fatalf("SetMaxMsgLen(%d) stored %d, want %d", tt.length, ss.maxMsgLen, tt.want) + } + }) + } +} + +type recordingErrorReader struct { + dataLengths chan<- int +} + +func (r recordingErrorReader) Read(_ Session, data []byte) (any, int, error) { + r.dataLengths <- len(data) + return nil, 0, errTestReadFailure +} + +func TestHandleUDPPackageUsesConfiguredReadBuffer(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + + sender, err := net.DialUDP("udp", nil, listener.LocalAddr().(*net.UDPAddr)) + if err != nil { + _ = listener.Close() + t.Fatal(err) + } + defer func() { + if err := sender.Close(); err != nil { + t.Errorf("close UDP sender: %v", err) + } + }() + + dataLengths := make(chan int, 1) + ss := newUDPSession(listener, newServer(UDP_ENDPOINT)).(*session) + ss.SetMaxMsgLen(1) + ss.SetReader(recordingErrorReader{dataLengths: dataLengths}) + want := udpReadBufferSize(1) + if want != 2 { + t.Fatalf("udpReadBufferSize(1) = %d, want 2", want) + } + + handlerDone := make(chan error, 1) + go func() { + handlerDone <- ss.handleUDPPackage() + }() + + handlerStopped := false + stopHandler := func() bool { + if handlerStopped { + return true + } + _ = listener.Close() + select { + case <-handlerDone: + handlerStopped = true + return true + case <-time.After(time.Second): + return false + } + } + defer func() { + if !stopHandler() { + t.Error("handleUDPPackage did not return after closing the UDP listener") + } + }() + + if _, err := sender.Write([]byte{1, 2, 3}); err != nil { + t.Fatal(err) + } + select { + case got := <-dataLengths: + if got != want { + t.Fatalf("Reader data length = %d, want udpReadBufferSize(1) = %d", got, want) + } + case <-time.After(time.Second): + t.Fatal("Reader did not receive the UDP datagram") + } + + if !stopHandler() { + t.Fatal("handleUDPPackage did not return after closing the UDP listener") + } +} + type errorReader struct{} func (errorReader) Read(Session, []byte) (any, int, error) {