diff --git a/.github/workflows/README.md b/.github/workflows/README.md index f314651..b01beaa 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,221 +1,49 @@ -# GitHub Actions 自动发布工作流 +# mlog 自动发布与严格质量门禁 -## 概述 +自动发布入口为 `release.yml`。它只接受 `main` / `master` 的 push,保留仅文档修改的忽略规则、`[skip release]` / `[no release]` 标记和版本提交循环保护。 -本项目配置了自动发布工作流,当代码推送到主分支(`main` 或 `master`)时,会自动执行打包发布流程。 +## 依赖链 -## 工作流程 - -1. **代码推送触发**:当代码推送到主分支时自动触发 -2. **环境检查**:检查 Go 环境和依赖 -3. **测试运行**:自动运行所有测试 -4. **代码检查**:执行代码格式化和静态检查 -5. **版本计算**:根据 commit 信息自动确定版本号 -6. **版本更新**:更新 `version.go` 和 `README.md` 中的版本信息 -7. **创建标签**:创建 Git 标签并推送 -8. **发布 Release**:在 GitHub 上创建正式发布 - -## 版本号规则 - -工作流会根据 commit 信息自动确定版本类型: - -### 主版本号递增(Major) -当 commit 信息包含以下内容时: -- `feat!:` 或 `feature!:` - 带有破坏性变更的新功能 -- `BREAKING CHANGE:` - 明确标注的破坏性变更 -- 包含 `breaking change` 关键词 - -**示例**: -```bash -git commit -m "feat!: 重构核心 API,不兼容旧版本" -git commit -m "BREAKING CHANGE: 修改日志接口签名" +```text +preflight(只读,检查是否需要发布) + └─ audit(只读,调用同一提交的 quality-audit.yml) + ├─ quality:格式、脚本测试、依赖校验、精确100%覆盖、race、vet、fuzz、基准正确性与基准运行 + ├─ security:工作流语法、受支持Go的race、govulncheck + └─ gate:两个结果均success,且两个audited_sha均等于触发SHA + └─ release(唯一contents:write作业) + ├─ 检查主分支没有前进;只在本地更新版本并创建候选提交 + ├─ 候选提交完整重跑quality + security + ├─ 保存候选验证证据;检查SHA和工作区、再次检查远端分支 + ├─ 原子推送候选提交和标签(无强推) + └─ 使用已存在的标签创建GitHub Release ``` -### 次版本号递增(Minor) -当 commit 信息以以下前缀开头时: -- `feat:` 或 `feature:` - 新功能 +`quality-audit.yml` 同时提供 PR、只读手动触发和 `workflow_call`。无需独立添加 push 检查:Auto Release 本身调用它,并通过 `needs` 与明确的成功条件阻断发布,不是与检查并行启动。 -**示例**: -```bash -git commit -m "feat: 添加异步日志支持" -git commit -m "feature: 新增配置热加载功能" -``` - -### 补丁版本号递增(Patch) -其他所有类型的 commit: -- `fix:` - Bug 修复 -- `docs:` - 文档更新 -- `style:` - 代码格式化 -- `refactor:` - 代码重构 -- `perf:` - 性能优化 -- `test:` - 测试相关 -- `chore:` - 构建/工具相关 - -**示例**: -```bash -git commit -m "fix: 修复日志级别判断错误" -git commit -m "docs: 更新 API 文档" -git commit -m "perf: 优化日志写入性能" -``` +检查失败、取消、跳过、缺少结果、引用其他提交的成功结果、工具安装失败或漏洞库访问失败都不能放行。`always()` 仅用于汇总和保存证据,不用于发布。不存在 `continue-on-error` 或跳过质量检查的输入。 -## 跳过自动发布 +## 为什么检查两次 -如果某次提交不需要触发发布,可以在 commit 信息中添加以下标记: +第一次检查实际触发提交;原流程随后会修改 `version.go`。为防止“测A发B”,第二次针对本地版本候选提交完整执行相同脚本,成功后才允许将该候选提交和标签推送到远端。发布流程不会运行自动格式化、自动修复依赖或修改其他生产代码。 -```bash -git commit -m "docs: 更新文档 [skip release]" -git commit -m "chore: 更新依赖 [no release]" -``` +门禁脚本分别为 `scripts/quality_gate.sh` 和 `scripts/security_gate.sh`。根模块Go兼容基线保持1.24,检查使用Go1.24.13;安全检查使用Go1.27.1和govulncheck1.8.0。更新工具链时同时调整质量工作流、发布候选工作流和手动入口的固定版本。 -## 首次发布 +## 版本与兼容 -首次发布时,版本号将根据 commit 类型自动确定: -- 主版本更新 → `v1.0.0` -- 次版本更新 → `v0.1.0` -- 补丁更新 → `v0.0.1` +保留原有版本策略:`feat!` / `feature!` / `BREAKING CHANGE` 为major,普通feat/feature为minor,其余为patch。自动递增只使用正式的 `vX.Y.Z` 标签;首次分别为v1.0.0 / v0.1.0 / v0.0.1。不改变mlog的公开API、配置、Zap依赖或运行时行为。 ## 手动发布 -如果需要手动控制发布,仍然可以使用原有的 `release.sh` 脚本: - -```bash -# 自动递增补丁版本 -./release.sh init "修复bug" - -# 自动递增次版本 -./release.sh minor "新增功能" - -# 自动递增主版本 -./release.sh major "重大更新" - -# 手动指定版本 -./release.sh v1.2.3 "自定义版本发布" -``` - -## 工作流配置 - -### 触发条件 -- 推送到 `main` 或 `master` 分支 -- 忽略以下文件的变更: - - Markdown 文件(`**.md`) - - `.gitignore` - - `LICENSE` - -### 权限要求 -工作流需要 `contents: write` 权限来创建标签和发布。 - -### 环境要求 -- Ubuntu 最新版本 -- Go 版本从 `go.mod` 文件自动读取 +`./release.sh init "修复说明"`、`minor`、`major`、显式版本号等原入口保留。手动脚本在版本变更提交之后也调用两套完整门禁;确认发布前后验证候选SHA和工作区,推送分支及标签时使用 `git push --atomic`,推送失败会返回非零,不会再报告成功。 -## 发布产物 +此脚本需要Bash、Git、Python3、Go、C编译器(race)及下载固定Go工具链/扫描器的网络。缺少工具或网络故障即停止;不要通过删测试或改覆盖率门槛来绕过。 -每次成功发布后,会生成: +## 证据与失败恢复 -1. **Git 标签**:格式为 `vX.Y.Z` -2. **GitHub Release**:包含变更日志和提交记录 -3. **更新的版本文件**: - - `version.go`(如果存在) - - `README.md`(如果包含 `go get` 命令) +Actions保存 `quality-results`、`security-results`、`release-candidate-evidence`(14天)。其中包含覆盖率原始文件、精确计数、race/vet/fuzz、基准输出、扫描结果和经过验证的SHA。 -## 最佳实践 - -### 1. 使用语义化提交信息 -遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范: - -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -### 2. 合理使用版本类型 -- **Patch(补丁)**:向后兼容的 bug 修复 -- **Minor(次版本)**:向后兼容的新功能 -- **Major(主版本)**:不兼容的 API 变更 - -### 3. 编写清晰的提交信息 -提交信息会被包含在发布说明中,应该: -- 简洁明了 -- 描述变更内容 -- 说明变更原因(如果必要) - -### 4. 确保测试通过 -工作流会自动运行测试,确保: -- 所有测试用例通过 -- 代码格式符合规范 -- 静态检查无错误 - -## 故障排查 - -### 发布失败 -如果自动发布失败,检查: -1. GitHub Actions 日志中的错误信息 -2. 测试是否全部通过 -3. 代码格式是否规范 -4. 是否有权限问题 - -### 版本冲突 -如果出现版本号冲突: -1. 检查是否有未推送的标签 -2. 手动删除冲突的标签:`git tag -d vX.Y.Z` -3. 重新推送代码触发发布 - -### 跳过发布未生效 -确保在 commit 信息中正确添加了跳过标记: -- `[skip release]` -- `[no release]` -- `[skip-release]` -- `[no-release]` - -## 示例工作流 - -### 场景 1:修复 Bug -```bash -# 修复代码 -git add . -git commit -m "fix: 修复日志文件锁定问题" -git push origin main - -# 自动触发发布,版本号从 v1.2.3 → v1.2.4 -``` - -### 场景 2:添加新功能 -```bash -# 开发新功能 -git add . -git commit -m "feat: 添加日志轮转功能" -git push origin main - -# 自动触发发布,版本号从 v1.2.4 → v1.3.0 -``` - -### 场景 3:重大更新 -```bash -# 重构 API -git add . -git commit -m "feat!: 重构日志接口,简化使用方式 - -BREAKING CHANGE: Logger.Write() 方法签名已更改" -git push origin main - -# 自动触发发布,版本号从 v1.3.0 → v2.0.0 -``` - -### 场景 4:仅更新文档 -```bash -# 更新文档 -git add . -git commit -m "docs: 更新 API 使用示例 [skip release]" -git push origin main - -# 不会触发发布 -``` +若源分支在检查期间前进,旧候选不发布;让新提交触发流程。若分支或标签冲突,原子推送失败,不强推、不覆盖已发布标签。若标签已推送而GitHub Release API失败,已推送的标签仍是经过验证的候选;核对原运行证据和标签SHA后,只补建该标签的Release。不要删除重打标签,也不要用新main伪装成同一版本。 -## 相关链接 +这约束仓库自带的自动与手动发布路径,不是管理员无法绕过的权限隔离。拥有写权限的人仍可能直接git push标签/API创建Release;需要组织级禁止绕过时,应另配GitHub标签ruleset、分支保护和发布环境审批。当前改动不擅自修改仓库管理员权限。 -- [GitHub Actions 文档](https://docs.github.com/en/actions) -- [语义化版本规范](https://semver.org/lang/zh-CN/) -- [Conventional Commits](https://www.conventionalcommits.org/) \ No newline at end of file +官方机制说明:https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows diff --git a/.github/workflows/quality-audit.yml b/.github/workflows/quality-audit.yml index fac8f99..9d9066a 100644 --- a/.github/workflows/quality-audit.yml +++ b/.github/workflows/quality-audit.yml @@ -2,20 +2,27 @@ name: Quality audit on: pull_request: workflow_dispatch: + workflow_call: + outputs: + audited_sha: + description: Exact commit approved by every required gate + value: ${{ jobs.gate.outputs.audited_sha }} permissions: contents: read +# A called workflow inherits github.workflow; the prefix must differ from Auto Release. concurrency: - group: quality-audit-${{ github.ref }} + group: quality-audit-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: quality: runs-on: ubuntu-latest - timeout-minutes: 15 - env: - GOMAXPROCS: '4' + timeout-minutes: 20 + outputs: + audited_sha: ${{ steps.validated.outputs.audited_sha }} steps: - uses: actions/checkout@v4 with: + ref: ${{ github.sha }} persist-credentials: false - uses: actions/setup-go@v5 with: @@ -23,61 +30,68 @@ jobs: cache-dependency-path: | go.sum benchmarks/go.sum - - name: Environment + - name: Strict quality checks + run: bash scripts/quality_gate.sh "$RUNNER_TEMP/quality-results" + - name: Record validated commit + id: validated run: | - mkdir -p audit-results - { go version; go env GOOS GOARCH GOMOD; lscpu; git rev-parse HEAD; } > audit-results/environment.txt - - name: Exact statement coverage gate - shell: bash - run: | - set -euo pipefail - python3 -m unittest discover -s scripts -p 'test_*.py' - go test -count=1 -timeout=180s -covermode=atomic -coverpkg=./... -coverprofile=audit-results/coverage.out ./... 2>&1 | tee audit-results/test.txt - go tool cover -func=audit-results/coverage.out | tee audit-results/coverage.txt - python3 scripts/check_coverage.py audit-results/coverage.out | tee audit-results/coverage.json - - name: Race and vet gates - shell: bash - run: | - set -euo pipefail - go test -race -count=1 -timeout=180s ./... 2>&1 | tee audit-results/race.txt - go vet ./... 2>&1 | tee audit-results/vet.txt - go test -run '^$' -fuzz '^FuzzAuditRouteComponents$' -fuzztime=5s ./ 2>&1 | tee audit-results/fuzz.txt - - name: Benchmark correctness gate and comparable measurements - shell: bash - run: | - set -euo pipefail - cd benchmarks - go test -race -count=1 ./... 2>&1 | tee ../audit-results/benchmark-correctness.txt - go test -run '^$' -bench . -benchmem -benchtime=200ms -count=5 -cpu=4 ./... 2>&1 | tee ../audit-results/benchmarks.txt + test "$(cat "$RUNNER_TEMP/quality-results/validated-sha.txt")" = "$GITHUB_SHA" + echo "audited_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 if: always() with: name: quality-results - path: audit-results/ + path: ${{ runner.temp }}/quality-results/ retention-days: 14 + if-no-files-found: error security: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 + outputs: + audited_sha: ${{ steps.validated.outputs.audited_sha }} steps: - uses: actions/checkout@v4 with: + ref: ${{ github.sha }} persist-credentials: false - uses: actions/setup-go@v5 with: go-version: '1.27.1' cache: true - - name: Current supported toolchain and vulnerability gate - shell: bash + - name: Validate workflow syntax run: | set -euo pipefail - mkdir -p security-results - go version | tee security-results/environment.txt - go test -race -count=1 -timeout=180s ./... 2>&1 | tee security-results/race.txt - go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 - govulncheck ./... 2>&1 | tee security-results/govulncheck.txt + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + "$(go env GOPATH)/bin/actionlint" -shellcheck= + - name: Supported toolchain and vulnerability checks + run: bash scripts/security_gate.sh "$RUNNER_TEMP/security-results" + - name: Record validated commit + id: validated + run: | + test "$(cat "$RUNNER_TEMP/security-results/validated-sha.txt")" = "$GITHUB_SHA" + echo "audited_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 if: always() with: name: security-results - path: security-results/ + path: ${{ runner.temp }}/security-results/*.txt retention-days: 14 + if-no-files-found: error + gate: + name: Strict quality gate + needs: [quality, security] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + audited_sha: ${{ steps.verdict.outputs.audited_sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require both successes for this exact commit + id: verdict + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: python3 scripts/require_quality.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 562590e..a711ec7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,245 +1,110 @@ name: Auto Release - on: push: - branches: - - main - - master + branches: [main, master] paths-ignore: - '**.md' - '.gitignore' - 'LICENSE' - permissions: - contents: write - + contents: read +# One publisher across BOTH release branches; never cancel a run during a push. +concurrency: + group: mlog-auto-release + cancel-in-progress: false jobs: + preflight: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + eligible: ${{ steps.plan.outputs.eligible }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Preserve release skip markers and version-commit guard + id: plan + run: python3 scripts/release_metadata.py eligible --source "$GITHUB_SHA" + audit: + needs: preflight + if: ${{ needs.preflight.outputs.eligible == 'true' }} + permissions: + contents: read + uses: ./.github/workflows/quality-audit.yml + # No secrets: inherit and no arbitrary checkout ref; audit the same commit. release: + needs: [preflight, audit] + if: >- + ${{ needs.preflight.outputs.eligible == 'true' && + needs.audit.result == 'success' && + needs.audit.outputs.audited_sha == github.sha }} runs-on: ubuntu-latest - + timeout-minutes: 40 + # This is the ONLY workflow job that can write tags or releases. + permissions: + contents: write + env: + SOURCE_SHA: ${{ github.sha }} + RELEASE_BRANCH: ${{ github.ref_name }} steps: - - name: 检出代码 - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Refuse a stale source; prepare version change locally only + id: candidate + shell: bash + run: | + set -euo pipefail + [[ "$RELEASE_BRANCH" == main || "$RELEASE_BRANCH" == master ]] + remote_sha="$(git ls-remote --exit-code origin "refs/heads/$RELEASE_BRANCH" | cut -f1)" + test "$remote_sha" = "$SOURCE_SHA" + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + python3 scripts/release_metadata.py prepare --source "$SOURCE_SHA" --notes "$RUNNER_TEMP/release-notes.md" + - uses: actions/setup-go@v5 with: - fetch-depth: 0 # 获取完整历史记录,用于版本比较 - - - name: 设置 Go 环境 - uses: actions/setup-go@v5 + go-version: '1.24.13' + cache-dependency-path: | + go.sum + benchmarks/go.sum + - name: Revalidate final candidate (including updated Version constant) + run: bash scripts/quality_gate.sh "$RUNNER_TEMP/candidate-quality" + - uses: actions/setup-go@v5 with: - go-version-file: 'go.mod' + go-version: '1.27.1' cache: true - - - name: 配置 Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: 检查是否需要发布 - id: check_release - run: | - # 获取最新的 commit 信息 - COMMIT_MSG=$(git log -1 --pretty=%B) - - # 检查 commit 信息中是否包含 [skip release] 或 [no release] - if echo "$COMMIT_MSG" | grep -qiE '\[(skip|no)[ -]?release\]'; then - echo "跳过发布:commit 信息包含跳过标记" - echo "should_release=false" >> $GITHUB_OUTPUT - exit 0 - fi - - # 检查是否为版本提交(避免重复发布) - if echo "$COMMIT_MSG" | grep -qE '^chore: bump version to v[0-9]+\.[0-9]+\.[0-9]+'; then - echo "跳过发布:这是一个版本提交" - echo "should_release=false" >> $GITHUB_OUTPUT - exit 0 - fi - - echo "should_release=true" >> $GITHUB_OUTPUT - - - name: 确定版本类型 - id: version_type - if: steps.check_release.outputs.should_release == 'true' - run: | - COMMIT_MSG=$(git log -1 --pretty=%B) - - # 根据 commit 信息确定版本类型 - if echo "$COMMIT_MSG" | grep -qiE '^(feat|feature)(\(.*\))?!:|^BREAKING CHANGE:|breaking change'; then - echo "type=major" >> $GITHUB_OUTPUT - echo "检测到重大更新,将递增主版本号" - elif echo "$COMMIT_MSG" | grep -qiE '^(feat|feature)(\(.*\))?:'; then - echo "type=minor" >> $GITHUB_OUTPUT - echo "检测到新功能,将递增次版本号" - else - echo "type=init" >> $GITHUB_OUTPUT - echo "检测到修复或其他更改,将递增补丁版本号" - fi - - - name: 运行测试 - if: steps.check_release.outputs.should_release == 'true' - run: | - if find . -name "*_test.go" -type f | grep -q .; then - echo "运行测试..." - go test -v ./... - else - echo "未发现测试文件,跳过测试" - fi - - - name: 代码格式检查 - if: steps.check_release.outputs.should_release == 'true' - run: | - # 检查代码格式 - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "发现格式不规范的文件,正在格式化..." - echo "$unformatted" - go fmt ./... - git add . - git commit -m "style: 自动格式化代码 [skip ci]" || true - fi - - # 运行 go vet - go vet ./... - - - name: 计算新版本号 - id: new_version - if: steps.check_release.outputs.should_release == 'true' - run: | - # 获取最新版本 - LATEST_VERSION=$(git tag -l | grep "^v" | sort -V | tail -1) - - if [ -z "$LATEST_VERSION" ]; then - # 首次发布 - case "${{ steps.version_type.outputs.type }}" in - "major") - NEW_VERSION="v1.0.0" - ;; - "minor") - NEW_VERSION="v0.1.0" - ;; - *) - NEW_VERSION="v0.0.1" - ;; - esac - else - # 递增版本号 - VERSION_WITHOUT_V=${LATEST_VERSION#v} - IFS='.' read -ra PARTS <<< "$VERSION_WITHOUT_V" - MAJOR=${PARTS[0]} - MINOR=${PARTS[1]} - PATCH=${PARTS[2]} - - case "${{ steps.version_type.outputs.type }}" in - "major") - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - "minor") - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - *) - PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="v$MAJOR.$MINOR.$PATCH" - fi - - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "新版本号: $NEW_VERSION" - - - name: 更新版本文件 - if: steps.check_release.outputs.should_release == 'true' - run: | - VERSION="${{ steps.new_version.outputs.version }}" - VERSION_WITHOUT_V=${VERSION#v} - MODULE_NAME=$(grep "^module " go.mod | awk '{print $2}') - - # 更新 version.go - if [ -f "version.go" ]; then - sed -i "s/const Version = \".*\"/const Version = \"$VERSION_WITHOUT_V\"/" version.go - git add version.go - fi - - # 更新 README.md - if [ -f "README.md" ] && grep -q "go get" README.md 2>/dev/null; then - sed -i "s|go get $MODULE_NAME@v[0-9]*\.[0-9]*\.[0-9]*|go get $MODULE_NAME@$VERSION|g" README.md - git add README.md - fi - - # 提交版本更新 - if ! git diff-index --quiet HEAD --; then - git commit -m "chore: bump version to $VERSION [skip ci]" - git push origin ${{ github.ref_name }} - fi - - - name: 生成变更日志 - id: changelog - if: steps.check_release.outputs.should_release == 'true' - run: | - VERSION="${{ steps.new_version.outputs.version }}" - LATEST_VERSION=$(git tag -l | grep "^v" | sort -V | tail -1) - - # 生成变更日志 - echo "## $VERSION ($(date +%Y-%m-%d))" > changelog.md - echo "" >> changelog.md - - # 获取 commit 信息 - COMMIT_MSG=$(git log -1 --pretty=%B) - echo "$COMMIT_MSG" >> changelog.md - echo "" >> changelog.md - - # 添加提交记录 - if [ -n "$LATEST_VERSION" ]; then - echo "### 提交记录:" >> changelog.md - git log --oneline "$LATEST_VERSION"..HEAD >> changelog.md - else - echo "### 提交记录 (首次发布):" >> changelog.md - git log --oneline --max-count=10 >> changelog.md - fi - - # 输出变更日志内容供后续使用 - CHANGELOG_CONTENT=$(cat changelog.md) - echo "content<> $GITHUB_OUTPUT - echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: 创建 Git 标签 - if: steps.check_release.outputs.should_release == 'true' - run: | - VERSION="${{ steps.new_version.outputs.version }}" - COMMIT_MSG=$(git log -1 --pretty=%B) - - git tag -a "$VERSION" -m "$COMMIT_MSG" - git push origin "$VERSION" - - - name: 创建 GitHub Release - if: steps.check_release.outputs.should_release == 'true' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Revalidate final candidate security + run: bash scripts/security_gate.sh "$RUNNER_TEMP/candidate-security" + - uses: actions/upload-artifact@v4 + if: always() with: - tag_name: ${{ steps.new_version.outputs.version }} - release_name: Release ${{ steps.new_version.outputs.version }} - body: ${{ steps.changelog.outputs.content }} - draft: false - prerelease: false - - - name: 发布信息 - if: steps.check_release.outputs.should_release == 'true' + name: release-candidate-evidence + path: | + ${{ runner.temp }}/candidate-quality/ + ${{ runner.temp }}/candidate-security/*.txt + ${{ runner.temp }}/release-notes.md + retention-days: 14 + if-no-files-found: error + - name: Publish only the exact fully validated candidate + shell: bash + env: + CANDIDATE_SHA: ${{ steps.candidate.outputs.candidate_sha }} + RELEASE_VERSION: ${{ steps.candidate.outputs.version }} + GH_TOKEN: ${{ github.token }} run: | - VERSION="${{ steps.new_version.outputs.version }}" - MODULE_NAME=$(grep "^module " go.mod | awk '{print $2}') - - echo "🎉 版本 $VERSION 发布成功!" - echo "" - echo "发布信息:" - echo "- 模块名称: $MODULE_NAME" - echo "- 版本: $VERSION" - echo "- 时间: $(date)" - echo "- 分支: ${{ github.ref_name }}" - echo "- 提交: $(git rev-parse --short HEAD)" - echo "" - echo "使用方法:" - echo " go get $MODULE_NAME@$VERSION" \ No newline at end of file + set -euo pipefail + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + test "$(git rev-parse HEAD^)" = "$SOURCE_SHA" + test "$(cat "$RUNNER_TEMP/candidate-quality/validated-sha.txt")" = "$CANDIDATE_SHA" + test "$(cat "$RUNNER_TEMP/candidate-security/validated-sha.txt")" = "$CANDIDATE_SHA" + git diff --exit-code HEAD -- + remote_sha="$(git ls-remote --exit-code origin "refs/heads/$RELEASE_BRANCH" | cut -f1)" + test "$remote_sha" = "$SOURCE_SHA" + # Atomic, non-force push: a branch advance or existing tag blocks BOTH refs. + git tag -a "$RELEASE_VERSION" "$CANDIDATE_SHA" -m "Release $RELEASE_VERSION" + git push --atomic origin "$CANDIDATE_SHA:refs/heads/$RELEASE_BRANCH" "refs/tags/$RELEASE_VERSION" + # Never let the release API manufacture a tag on an unchecked commit. + gh release create "$RELEASE_VERSION" --verify-tag --target "$CANDIDATE_SHA" \ + --title "Release $RELEASE_VERSION" --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/release.sh b/release.sh index 0fe3be9..d13c263 100755 --- a/release.sh +++ b/release.sh @@ -180,57 +180,26 @@ is_auto_increment() { # 运行测试 run_tests() { - log_info "运行测试..." - - # 检查是否有测试文件 - local has_tests=false - if find . -name "*_test.go" -type f | grep -q .; then - has_tests=true - fi - - if [[ "$has_tests" == "true" ]]; then - # 运行测试并捕获输出 - if ! go test -v ./... 2>&1; then - log_error "测试失败,请修复后重试" - log_info "提示: 可以运行 'go test -v ./...' 查看详细错误信息" - exit 1 - fi - log_success "所有测试通过" - else - log_warning "未发现测试文件,跳过测试步骤" - log_info "建议为项目添加单元测试以提高代码质量" - fi + log_info "运行完整质量门禁(不提供跳过选项)..." + local gate_dir + gate_dir=$(mktemp -d "${TMPDIR:-/tmp}/mlog-release.XXXXXX") + log_info "质量检查证据: $gate_dir" + GOTOOLCHAIN=go1.24.13 bash scripts/quality_gate.sh "$gate_dir/quality" + GOTOOLCHAIN=go1.27.1 bash scripts/security_gate.sh "$gate_dir/security" + RELEASE_VALIDATED_SHA=$(git rev-parse HEAD) + test "$(cat "$gate_dir/quality/validated-sha.txt")" = "$RELEASE_VALIDATED_SHA" + test "$(cat "$gate_dir/security/validated-sha.txt")" = "$RELEASE_VALIDATED_SHA" } # 运行代码检查 run_lint() { - log_info "运行代码检查..." - - # 检查 go fmt - local unformatted_files=$(gofmt -l .) + local unformatted_files + unformatted_files=$(gofmt -l .) if [[ -n "$unformatted_files" ]]; then - log_warning "发现格式不规范的文件,正在自动格式化..." - echo "$unformatted_files" - go fmt ./... - log_success "代码格式化完成" - - # 如果有格式化的文件,需要重新检查工作目录状态 - if ! git diff-index --quiet HEAD --; then - log_info "代码格式化产生了更改,将自动提交" - git add . - git commit -m "style: 自动格式化代码" - fi - else - log_success "代码格式检查通过" - fi - - # 检查 go vet - if ! go vet ./...; then - log_error "代码静态检查失败,请修复后重试" + log_error "代码格式不规范,请修复并提交后重试(发布流程不会自动修改代码)" + printf '%s\n' "$unformatted_files" exit 1 fi - - log_success "代码静态检查通过" } # 更新版本信息 @@ -260,7 +229,7 @@ update_version_info() { } # 生成变更日志 -generate_changelog() { + generate_changelog() { local version=$1 local message=$2 local latest_version=$(get_latest_version) @@ -294,37 +263,17 @@ generate_changelog() { create_release() { local version=$1 local message=$2 - local current_branch=$(git branch --show-current) - - log_info "创建 Git 标签 $version..." - - # 创建带注释的标签 - git tag -a "$version" -m "$message" - log_success "标签 $version 创建成功" - - # 检查是否有远程仓库 + local current_branch + current_branch=$(git branch --show-current) + test -n "${RELEASE_VALIDATED_SHA:-}" + test "$(git rev-parse HEAD)" = "$RELEASE_VALIDATED_SHA" + git diff --exit-code HEAD -- + git tag -a "$version" "$RELEASE_VALIDATED_SHA" -m "$message" if git remote | grep -q .; then - # 推送代码到远程仓库(如果有未推送的提交) - if ! git diff --quiet HEAD "origin/$current_branch" 2>/dev/null; then - log_info "推送代码到远程仓库..." - if git push origin "$current_branch"; then - log_success "代码已推送到远程仓库" - else - log_warning "代码推送失败,但标签已创建" - fi - fi - - # 推送标签到远程仓库 - log_info "推送标签到远程仓库..." - if git push origin "$version"; then - log_success "标签已推送到远程仓库" - else - log_error "标签推送失败" - log_info "可以稍后手动推送: git push origin $version" - fi + # Never publish a tag if updating its branch fails. + git push --atomic origin "HEAD:refs/heads/$current_branch" "refs/tags/$version" else - log_warning "未配置远程仓库,标签仅在本地创建" - log_info "如需推送到远程,请先配置远程仓库" + log_warning "未配置远程仓库,经过完整验证的标签仅在本地创建" fi } @@ -453,8 +402,7 @@ main() { check_main_branch check_version_exists "$version" - # 运行测试和代码检查 - run_tests + # 只检查格式,不在测试后偷偷改写源码 run_lint # 更新版本信息 @@ -466,6 +414,9 @@ main() { git commit -m "chore: bump version to $version" fi + # 在版本变更提交之后验证最终待发布内容;失败不创建或推送标签 + run_tests + # 生成变更日志 generate_changelog "$version" "$message" diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh new file mode 100644 index 0000000..db01a96 --- /dev/null +++ b/scripts/quality_gate.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# A single fail-closed entry point for PRs, automatic releases and release.sh. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +out="$(mkdir -p "${1:?usage: quality_gate.sh OUTPUT_DIR}" && cd "$1" && pwd)" +export GOMAXPROCS=4 +export GOFLAGS=-mod=readonly +source_sha="$(git rev-parse HEAD)" +{ go version; go env GOOS GOARCH GOMOD; git rev-parse HEAD; } > "$out/environment.txt" + +# Never mutate source during validation (in particular, never run go fmt). +unformatted="$(git ls-files -z -- '*.go' | xargs -0 gofmt -l)" +if [[ -n "$unformatted" ]]; then + printf 'Unformatted Go files; fix and commit before release:\n%s\n' "$unformatted" >&2 + exit 1 +fi +python3 -m unittest discover -s scripts -p 'test_*.py' 2>&1 | tee "$out/script-tests.txt" +go mod verify 2>&1 | tee "$out/modules.txt" +go test -count=1 -timeout=180s -covermode=atomic -coverpkg=./... -coverprofile="$out/coverage.out" ./... 2>&1 | tee "$out/test.txt" +go tool cover -func="$out/coverage.out" | tee "$out/coverage.txt" +python3 scripts/check_coverage.py "$out/coverage.out" | tee "$out/coverage.json" +go test -race -count=1 -timeout=180s ./... 2>&1 | tee "$out/race.txt" +go vet ./... 2>&1 | tee "$out/vet.txt" +go test -run '^$' -fuzz '^FuzzAuditRouteComponents$' -fuzztime=5s ./ 2>&1 | tee "$out/fuzz.txt" +( + cd benchmarks + go mod verify 2>&1 | tee "$out/benchmark-modules.txt" + go test -race -count=1 -timeout=180s ./... 2>&1 | tee "$out/benchmark-correctness.txt" + go vet ./... 2>&1 | tee "$out/benchmark-vet.txt" + go test -run '^$' -bench . -benchmem -benchtime=200ms -count=5 -cpu=4 ./... 2>&1 | tee "$out/benchmarks.txt" +) +test "$(git rev-parse HEAD)" = "$source_sha" +git diff --exit-code "$source_sha" -- +printf '%s\n' "$source_sha" > "$out/validated-sha.txt" diff --git a/scripts/release_metadata.py b/scripts/release_metadata.py new file mode 100644 index 0000000..5e3d2dd --- /dev/null +++ b/scripts/release_metadata.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Prepare release metadata locally. No network writes, tags or GitHub releases.""" +import argparse +import os +from pathlib import Path +import re +import subprocess + + +def git(*args): + return subprocess.check_output(["git", *args], text=True).strip() + + +def eligible(message): + return not (re.search(r"\[(?:skip|no)[ -]?release\]", message, re.I) + or re.match(r"chore: bump version to v[0-9]+\.[0-9]+\.[0-9]+", message)) + + +def next_version(tags, message): + versions = [tuple(map(int, m.groups())) for tag in tags + if (m := re.fullmatch(r"v([0-9]+)\.([0-9]+)\.([0-9]+)", tag))] + major, minor, patch = max(versions, default=(0, 0, 0)) + if re.search(r"^(?:feat|feature)(?:\(.*\))?!:|^BREAKING CHANGE:|breaking change", message, re.I | re.M): + major, minor, patch = major + 1, 0, 0 + elif re.search(r"^(?:feat|feature)(?:\(.*\))?:", message, re.I): + minor, patch = minor + 1, 0 + else: + patch += 1 + return f"v{major}.{minor}.{patch}" + + +def emit(**values): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + for key, value in values.items(): + out.write(f"{key}={value}\n") + + +def prepare(source, notes): + if git("rev-parse", "HEAD") != source or git("status", "--porcelain", "--untracked-files=no"): + raise ValueError("release must start at the clean, audited source commit") + message = git("log", "-1", "--format=%B") + if not eligible(message): + raise ValueError("release was explicitly skipped") + tags = git("tag", "--list").splitlines() + version = next_version(tags, message) + p = Path("version.go") + updated, count = re.subn(r'^const Version = "[^"\n]+"$', f'const Version = "{version[1:]}"', p.read_text(), flags=re.M) + if count != 1: + raise ValueError("expected exactly one Version constant") + p.write_text(updated) + files = ["version.go"] + readme = Path("README.md") + if readme.exists(): + module = re.search(r"^module\s+(\S+)$", Path("go.mod").read_text(), re.M).group(1) + readme.write_text(re.sub(r"go get " + re.escape(module) + r"@v[0-9]+\.[0-9]+\.[0-9]+", "go get " + module + "@" + version, readme.read_text())) + files.append("README.md") + subprocess.run(["git", "add", "--", *files], check=True) + subprocess.run(["git", "commit", "-m", f"chore: bump version to {version} [skip ci]"], check=True) + candidate = git("rev-parse", "HEAD") + if git("rev-parse", "HEAD^") != source: + raise ValueError("candidate is not a direct child of audited source") + notes.write_text(f"## {version}\n\n{message}\n\nValidated source: `{source}`\nRelease candidate: `{candidate}`\n") + emit(version=version, candidate_sha=candidate) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=["eligible", "prepare"]) + parser.add_argument("--source", required=True) + parser.add_argument("--notes", type=Path) + args = parser.parse_args() + if not re.fullmatch(r"[0-9a-f]{40}", args.source) or git("rev-parse", "HEAD") != args.source: + parser.error("checkout must match the exact triggering SHA") + if args.mode == "eligible": + emit(eligible=str(eligible(git("log", "-1", "--format=%B"))).lower()) + else: + if args.notes is None: + parser.error("--notes is required for prepare") + prepare(args.source, args.notes) + + +if __name__ == "__main__": + main() diff --git a/scripts/require_quality.py b/scripts/require_quality.py new file mode 100644 index 0000000..dc0d754 --- /dev/null +++ b/scripts/require_quality.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Reject missing, skipped, failed, cancelled or wrong-commit quality jobs.""" +import json +import os +import re +import sys + + +def require_success(needs, source_sha): + if not isinstance(source_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", source_sha): + raise ValueError("invalid source SHA") + if not isinstance(needs, dict) or set(needs) != {"quality", "security"}: + raise ValueError("both quality and security results are required") + for name, job in needs.items(): + if not isinstance(job, dict) or job.get("result") != "success": + raise ValueError(f"{name} did not succeed") + outputs = job.get("outputs", {}) + if not isinstance(outputs, dict) or outputs.get("audited_sha") != source_sha: + raise ValueError(f"{name} did not validate this exact commit") + return source_sha + + +def main(): + try: + sha = require_success(json.loads(os.environ["NEEDS_JSON"]), os.environ["GITHUB_SHA"]) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"audited_sha={sha}\n") + print(f"Strict quality gate passed for {sha}") + except (KeyError, ValueError, OSError) as exc: + print(f"Strict quality gate BLOCKED: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/security_gate.sh b/scripts/security_gate.sh new file mode 100644 index 0000000..1edaa2e --- /dev/null +++ b/scripts/security_gate.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Use the supported toolchain selected by setup-go / GOTOOLCHAIN, not the minimum Go baseline. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +out="$(mkdir -p "${1:?usage: security_gate.sh OUTPUT_DIR}" && cd "$1" && pwd)" +export GOFLAGS=-mod=readonly +source_sha="$(git rev-parse HEAD)" +{ go version; git rev-parse HEAD; } > "$out/environment.txt" +go test -race -count=1 -timeout=180s ./... 2>&1 | tee "$out/race.txt" +# Isolated pinned scanner; do not accidentally execute an old binary from PATH. +mkdir -p "$out/tools" +GOBIN="$out/tools" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 +# Text mode and pipefail are intentional: any finding, scan or network error is fatal. +"$out/tools/govulncheck" ./... 2>&1 | tee "$out/govulncheck.txt" +test "$(git rev-parse HEAD)" = "$source_sha" +git diff --exit-code "$source_sha" -- +printf '%s\n' "$source_sha" > "$out/validated-sha.txt" diff --git a/scripts/test_release_gate.py b/scripts/test_release_gate.py new file mode 100644 index 0000000..d249c00 --- /dev/null +++ b/scripts/test_release_gate.py @@ -0,0 +1,271 @@ +"""Release barriers: real policy/metadata, injected tool failures, and workflow wiring.""" +import copy +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import release_metadata +from require_quality import require_success + +ROOT = Path(__file__).resolve().parents[1] +SHA = "a" * 40 +CANDIDATE = "b" * 40 + + +def run(*args, cwd=None, env=None): + return subprocess.run(args, cwd=cwd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + +def initialize(path): + for args in [("init", "-q"), ("config", "user.name", "Test"), ("config", "user.email", "test@example.invalid")]: + result = run("git", *args, cwd=path) + assert result.returncode == 0, result.stdout + + +def commit(path, message): + assert run("git", "add", ".", cwd=path).returncode == 0 + result = run("git", "commit", "-qm", message, cwd=path) + assert result.returncode == 0, result.stdout + return run("git", "rev-parse", "HEAD", cwd=path).stdout.strip() + + +class PolicyTests(unittest.TestCase): + def setUp(self): + self.needs = {key: {"result": "success", "outputs": {"audited_sha": SHA}} for key in ("quality", "security")} + + def test_only_two_exact_successes_pass(self): + self.assertEqual(require_success(self.needs, SHA), SHA) + + def test_every_non_success_is_blocked(self): + for job in self.needs: + for status in ("failure", "cancelled", "skipped", "", None, True, "in_progress"): + with self.subTest(job=job, status=status), self.assertRaises(ValueError): + needs = copy.deepcopy(self.needs) + needs[job]["result"] = status + require_success(needs, SHA) + + def test_wrong_or_missing_sha_is_blocked(self): + for job in self.needs: + for outputs in ({}, None, {"audited_sha": CANDIDATE}, {"audited_sha": ""}): + with self.subTest(job=job, outputs=outputs), self.assertRaises(ValueError): + needs = copy.deepcopy(self.needs) + needs[job]["outputs"] = outputs + require_success(needs, SHA) + + def test_missing_malformed_extra_jobs_are_blocked(self): + for needs in ({}, [], None, {"quality": self.needs["quality"]}, {**self.needs, "other": {}}, {"quality": None, "security": {}}): + with self.subTest(needs=needs), self.assertRaises(ValueError): + require_success(needs, SHA) + for sha in ("main", "", "a" * 39, None): + with self.assertRaises(ValueError): + require_success(self.needs, sha) + + def test_cli_does_not_emit_approval_on_invalid_json(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "output" + env = dict(os.environ, NEEDS_JSON="not-json", GITHUB_SHA=SHA, GITHUB_OUTPUT=str(out)) + result = run("python3", str(ROOT / "scripts/require_quality.py"), env=env) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(out.exists()) + + +class MetadataTests(unittest.TestCase): + def test_release_skip_markers(self): + for message in ("fix: x [skip release]", "fix: x [NO-RELEASE]", "fix: x [norelease]", "chore: bump version to v0.0.22 [skip ci]"): + self.assertFalse(release_metadata.eligible(message)) + self.assertTrue(release_metadata.eligible("fix: release checks")) + + def test_version_selection(self): + tags = ["v0.0.9", "v0.0.22", "other", "v100.0.0-beta"] + self.assertEqual(release_metadata.next_version(tags, "fix: x"), "v0.0.23") + self.assertEqual(release_metadata.next_version(tags, "feat: x"), "v0.1.0") + self.assertEqual(release_metadata.next_version(tags, "feat(core)!: x"), "v1.0.0") + self.assertEqual(release_metadata.next_version([], "fix: first"), "v0.0.1") + + def test_candidate_is_local_exact_child_with_only_metadata_changes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + initialize(root) + (root / "version.go").write_text('package mlog\nconst Version = "0.0.22"\n') + (root / "go.mod").write_text('module github.com/PinableAgents/mlog\n') + (root / "README.md").write_text('go get github.com/PinableAgents/mlog@v0.0.22\n') + source = commit(root, "fix: gates") + self.assertEqual(run("git", "tag", "v0.0.22", cwd=root).returncode, 0) + env = dict(os.environ, GITHUB_OUTPUT=str(root / "outputs")) + result = run("python3", str(ROOT / "scripts/release_metadata.py"), "prepare", "--source", source, "--notes", str(root / "notes"), cwd=root, env=env) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(run("git", "rev-parse", "HEAD^", cwd=root).stdout.strip(), source) + self.assertEqual(set(run("git", "diff", "--name-only", "HEAD^", "HEAD", cwd=root).stdout.splitlines()), {"version.go", "README.md"}) + self.assertIn('Version = "0.0.23"', (root / "version.go").read_text()) + self.assertEqual(run("git", "tag", "--list", cwd=root).stdout.strip(), "v0.0.22") + self.assertIn("candidate_sha=", (root / "outputs").read_text()) + dirty = root / "version.go" + dirty.write_text(dirty.read_text() + "// dirty\n") + source = run("git", "rev-parse", "HEAD", cwd=root).stdout.strip() + result = run("python3", str(ROOT / "scripts/release_metadata.py"), "prepare", "--source", source, "--notes", str(root / "notes"), cwd=root, env=env) + self.assertNotEqual(result.returncode, 0) + + +# These are explicitly fake tools, used only in isolated temporary repositories. +FAKE_GO = '''#!/usr/bin/env python3 +import os,sys +from pathlib import Path +a=sys.argv[1:] +bench=Path.cwd().name == "benchmarks" +if a[0] == "version": print("go version test-fixture");sys.exit(0) +if a[0] == "env": print("fixture");sys.exit(0) +if a[0] == "mod": stage="benchmark-modules" if bench else "modules" +elif a[0] == "install": stage="scanner-install" +elif a[0] == "tool": stage="cover-report" +elif a[0] == "vet": stage="benchmark-vet" if bench else "vet" +elif any(x.startswith("-coverprofile=") for x in a): stage="coverage" +elif "-fuzz" in a: stage="fuzz" +elif "-bench" in a: stage="benchmarks" +else: stage="benchmark-correctness" if bench else "race" +with open(os.environ["TRACE"],"a") as f: f.write(stage+"\\n") +if stage == os.environ.get("FAIL_STAGE"): sys.exit(37) +if stage == "coverage": + p=next(x.split("=",1)[1] for x in a if x.startswith("-coverprofile=")) + count="0" if os.environ.get("FAIL_STAGE")=="coverage-gap" else "1" + Path(p).write_text("mode: atomic\\nexample/x.go:1.1,2.1 1 "+count+"\\n") +if stage == "scanner-install": + p=Path(os.environ["GOBIN"])/"govulncheck" + p.write_text('#!/bin/sh\\necho scanner >> "$TRACE"\\n[ "$FAIL_STAGE" != scanner ]\\n') + p.chmod(0o755) +''' + + +class FailClosedCommandTests(unittest.TestCase): + def check_script(self, name, fail_stage): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "repo" + root.mkdir() + initialize(root) + (root / "x.go").write_text("package example\n") + (root / "benchmarks").mkdir() + (root / "benchmarks/go.mod").write_text("module example/benchmarks\n") + (root / "scripts").mkdir() + (root / "scripts/test_fixture.py").write_text("import unittest\nclass Fixture(unittest.TestCase):\n def test_ok(self): self.assertTrue(True)\n") + (root / "scripts/check_coverage.py").write_bytes((ROOT / "scripts/check_coverage.py").read_bytes()) + commit(root, "fixture") + tools = Path(tmp) / "tools" + tools.mkdir() + for n, text in {"go": FAKE_GO, "gofmt": '#!/bin/sh\n[ "$FAIL_STAGE" != format ] || echo unformatted.go\n'}.items(): + p = tools / n + p.write_text(text) + p.chmod(0o755) + out = Path(tmp) / "results" + trace = Path(tmp) / "trace" + env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ["PATH"], FAIL_STAGE=fail_stage, TRACE=str(trace)) + result = run("bash", str(ROOT / "scripts" / name), str(out), cwd=root, env=env) + if fail_stage: + self.assertNotEqual(result.returncode, 0, (fail_stage, result.stdout)) + self.assertFalse((out / "validated-sha.txt").exists(), result.stdout) + else: + self.assertEqual(result.returncode, 0, result.stdout) + self.assertTrue((out / "validated-sha.txt").exists()) + + def test_quality_pass_and_every_failure_stage(self): + stages = ("", "format", "modules", "coverage", "coverage-gap", "cover-report", "race", "vet", "fuzz", "benchmark-modules", "benchmark-correctness", "benchmark-vet", "benchmarks") + with ThreadPoolExecutor(max_workers=6) as pool: + futures = [(stage, pool.submit(self.check_script, "quality_gate.sh", stage)) for stage in stages] + for stage, future in futures: + with self.subTest(stage=stage): + future.result() + + def test_security_pass_install_scan_and_race_failures(self): + with ThreadPoolExecutor(max_workers=4) as pool: + futures = [(stage, pool.submit(self.check_script, "security_gate.sh", stage)) for stage in ("", "race", "scanner-install", "scanner")] + for stage, future in futures: + with self.subTest(stage=stage): + future.result() + + +class WorkflowTests(unittest.TestCase): + def test_dependency_permissions_and_checked_ref(self): + release = (ROOT / ".github/workflows/release.yml").read_text() + audit = (ROOT / ".github/workflows/quality-audit.yml").read_text() + self.assertIn("workflow_call:", audit) + self.assertIn("needs: [quality, security]", audit) + self.assertIn("python3 scripts/require_quality.py", audit) + self.assertIn("needs: [preflight, audit]", release) + self.assertIn("needs.audit.result == 'success'", release) + self.assertIn("needs.audit.outputs.audited_sha == github.sha", release) + self.assertEqual(release.count("contents: write"), 1) + self.assertNotIn("contents: write", audit) + self.assertNotIn("continue-on-error:", release + audit) + self.assertNotIn("secrets: inherit\n", release) + self.assertIn("uses: ./.github/workflows/quality-audit.yml", release) + self.assertIn("cancel-in-progress: false", release) + self.assertEqual(audit.count("ref: ${{ github.sha }}"), 3) + + def test_final_candidate_rechecked_before_any_publication(self): + text = (ROOT / ".github/workflows/release.yml").read_text() + self.assertLess(text.index("scripts/release_metadata.py prepare"), text.index("scripts/quality_gate.sh")) + self.assertLess(text.index("scripts/quality_gate.sh"), text.index("scripts/security_gate.sh")) + self.assertLess(text.index("scripts/security_gate.sh"), text.index("git tag -a")) + self.assertIn("git push --atomic", text) + self.assertNotIn("--force", text) + self.assertIn("--verify-tag", text) + self.assertNotIn("go fmt", text) + self.assertNotIn("actions/create-release", text) + + def test_manual_release_has_no_bypass(self): + text = (ROOT / "release.sh").read_text() + main = text[text.index("main() {"):] + self.assertLess(main.index('git commit -m'), main.index(" run_tests\n")) + self.assertLess(main.index(" run_tests\n"), main.index(' create_release')) + self.assertIn("scripts/quality_gate.sh", text) + self.assertIn("scripts/security_gate.sh", text) + self.assertNotIn("go fmt ./...", text) + self.assertIn("git push --atomic", text) + + def test_publish_shell_aborts_on_stale_dirty_mismatch_or_push_failure(self): + text = (ROOT / ".github/workflows/release.yml").read_text() + block = text.split(" - name: Publish only the exact fully validated candidate\n", 1)[1] + block = block.split(" run: |\n", 1)[1].split(" - uses:", 1)[0] + shell = "\n".join(line[10:] for line in block.splitlines()) + "\n" + for failure in ("", "stale", "dirty", "wrong-quality", "wrong-security", "push"): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) + for folder in ("candidate-quality", "candidate-security"): + (p / folder).mkdir() + wrong = failure == "wrong-" + folder.removeprefix("candidate-") + (p / folder / "validated-sha.txt").write_text(SHA if wrong else CANDIDATE) + trace = p / "trace" + fake_git = '''#!/usr/bin/env python3 +import os,sys +a=sys.argv[1:] +if a[0] == "rev-parse": print(os.environ["SOURCE_SHA"] if a[1]=="HEAD^" else os.environ["CANDIDATE_SHA"]) +elif a[0]=="diff": sys.exit(1 if os.environ["FAIL"]=="dirty" else 0) +elif a[0]=="ls-remote": print(("c"*40 if os.environ["FAIL"]=="stale" else os.environ["SOURCE_SHA"])+"\\trefs/heads/main") +elif a[0]=="push": + with open(os.environ["TRACE"],"a") as f:f.write("push\\n") + sys.exit(1 if os.environ["FAIL"]=="push" else 0) +elif a[0]=="tag": + with open(os.environ["TRACE"],"a") as f:f.write("tag\\n") +else: sys.exit(98) +''' + for name, body in {"git": fake_git, "gh": '#!/bin/sh\necho release >> "$TRACE"\n'}.items(): + f = p / name + f.write_text(body) + f.chmod(0o755) + env = dict(os.environ, PATH=tmp + os.pathsep + os.environ["PATH"], RUNNER_TEMP=tmp, SOURCE_SHA=SHA, CANDIDATE_SHA=CANDIDATE, RELEASE_BRANCH="main", RELEASE_VERSION="v0.0.23", FAIL=failure, TRACE=str(trace)) + result = run("bash", "-c", shell, env=env) + actions = trace.read_text().splitlines() if trace.exists() else [] + if failure: + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertNotIn("release", actions) + if failure != "push": + self.assertEqual(actions, []) + else: + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(actions, ["tag", "push", "release"]) + + +if __name__ == "__main__": + unittest.main()