From 05bc36c515d0d6aba244218ebbeb61e8172d2fe8 Mon Sep 17 00:00:00 2001 From: cl-ment Date: Mon, 24 Aug 2026 18:17:41 +0200 Subject: [PATCH 1/9] ci: harden workflow permissions, add timeouts, concurrency and coverage report --- .github/workflows/ci.yml | 191 ++++++++++++++++++++++++--------------- 1 file changed, 118 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63b364fd..7a39eeba5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,76 +6,133 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + jobs: - smoke: - name: Smoke (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - macos-latest - - windows-latest + quality: + name: Code Quality & Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0 with: go-version-file: go.mod cache: true - - name: Install Make on Windows - if: matrix.os == 'windows-latest' - shell: pwsh - run: choco install make --no-progress -y - - - name: Validate quality targets with stock macOS Make - if: matrix.os == 'macos-latest' - env: - GIT_CONFIG_COUNT: 1 - GIT_CONFIG_KEY_0: grep.patternType - GIT_CONFIG_VALUE_0: fixed - run: make -n vulncheck - - - name: Check vulnerabilities through native Windows Make - if: matrix.os == 'windows-latest' - shell: cmd - run: make vulncheck - - - name: Check dead code through native Windows Make - if: matrix.os == 'windows-latest' - continue-on-error: true - shell: cmd - run: make deadcode - - - name: Run static lint through native Windows Make - if: matrix.os == 'windows-latest' - continue-on-error: true - shell: cmd - run: make lint-static - - name: Check formatting - if: matrix.os == 'ubuntu-latest' shell: bash run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then - echo "gofmt needed on:" >&2 + echo "::error::gofmt needed on:" >&2 echo "$unformatted" >&2 exit 1 fi - name: Vet - if: matrix.os == 'ubuntu-latest' run: go vet ./... - - name: Test + - name: deadcode (advisory) + continue-on-error: true + run: make deadcode + + - name: golangci-lint (advisory) + continue-on-error: true + run: make lint-static + + test: + name: Unit Tests & Race Detector + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Test with race detector & coverage + run: | + go test -race -count=1 -coverprofile=coverage.out -covermode=atomic ./... + + - name: Publish coverage summary + if: always() + shell: bash + run: | + if [ -f coverage.out ]; then + total_cov=$(go tool cover -func=coverage.out | grep 'total:' | awk '{print $3}') + echo "### 📊 Test Coverage Summary: **${total_cov}**" >> $GITHUB_STEP_SUMMARY + echo "
Coverage per package" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Package | Function | Coverage |" >> $GITHUB_STEP_SUMMARY + echo "| :--- | :--- | :--- |" >> $GITHUB_STEP_SUMMARY + go tool cover -func=coverage.out | grep -v 'total:' | tail -n 25 | while read -r line; do + pkg=$(echo "$line" | awk '{print $1}') + fn=$(echo "$line" | awk '{print $2}') + cov=$(echo "$line" | awk '{print $3}') + echo "| \`$pkg\` | \`$fn\` | **$cov** |" >> $GITHUB_STEP_SUMMARY + done + echo "
" >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: zero-coverage-report + path: coverage.out + if-no-files-found: warn + + smoke: + name: Smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Test (Fast Non-Race) run: go test ./... - name: Build binary @@ -87,15 +144,18 @@ jobs: performance: name: Performance Smoke runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0 with: go-version-file: go.mod cache: true @@ -104,51 +164,36 @@ jobs: run: go run ./cmd/zero-release build - name: Performance smoke - run: go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci + run: | + mkdir -p dist/perf + go run ./cmd/zero-perf-bench --output dist/perf/perf-bench.json --ci - name: Upload performance report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: zero-performance-smoke path: dist/perf/perf-bench.json if-no-files-found: warn security: - name: Security & code health + name: Security & Code Health runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.3.0 with: go-version-file: go.mod cache: true - # Hard gate: fails the build when code reaches a known vulnerability. A stdlib - # CVE is cleared by a toolchain bump (see go.mod). May also flag a newly - # published advisory on an unrelated PR — intentional: do not ship known vulns. - name: govulncheck run: make vulncheck - - # Advisory: reports functions unreachable from any cmd/* main so dormant - # code is visible in CI. Non-blocking while the dormant subsystems are - # still being wired or removed. - - name: deadcode (advisory) - continue-on-error: true - run: make deadcode - - # Advisory: catches what deadcode's whole-program reachability analysis - # doesn't, unused private functions/assignments reachable within a - # package but never actually called, plus staticcheck-style correctness - # and readability issues. Scoped to a few linters rather than the full - # default battery, and non-blocking, while the existing findings across - # the repo are cleaned up incrementally (see #527). - - name: golangci-lint (advisory) - continue-on-error: true - run: make lint-static From 90cf681d42c1e755b6af4178d98e40ce666ef9a5 Mon Sep 17 00:00:00 2001 From: cl-ment Date: Mon, 24 Aug 2026 18:44:29 +0200 Subject: [PATCH 2/9] ci: reuse make fmt-check and refine coverage table formatting --- .github/workflows/ci.yml | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a39eeba5..726306fdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,14 +34,7 @@ jobs: cache: true - name: Check formatting - shell: bash - run: | - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "::error::gofmt needed on:" >&2 - echo "$unformatted" >&2 - exit 1 - fi + run: make fmt-check - name: Vet run: go vet ./... @@ -83,18 +76,20 @@ jobs: run: | if [ -f coverage.out ]; then total_cov=$(go tool cover -func=coverage.out | grep 'total:' | awk '{print $3}') - echo "### 📊 Test Coverage Summary: **${total_cov}**" >> $GITHUB_STEP_SUMMARY - echo "
Coverage per package" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Package | Function | Coverage |" >> $GITHUB_STEP_SUMMARY - echo "| :--- | :--- | :--- |" >> $GITHUB_STEP_SUMMARY - go tool cover -func=coverage.out | grep -v 'total:' | tail -n 25 | while read -r line; do - pkg=$(echo "$line" | awk '{print $1}') - fn=$(echo "$line" | awk '{print $2}') - cov=$(echo "$line" | awk '{print $3}') - echo "| \`$pkg\` | \`$fn\` | **$cov** |" >> $GITHUB_STEP_SUMMARY - done - echo "
" >> $GITHUB_STEP_SUMMARY + { + echo "### 📊 Test Coverage Summary: **${total_cov}**" + echo "
Coverage per function" + echo "" + echo "| File | Function | Coverage |" + echo "| :--- | :--- | :--- |" + go tool cover -func=coverage.out | grep -v 'total:' | while read -r line; do + file=$(echo "$line" | awk '{print $1}') + fn=$(echo "$line" | awk '{print $2}') + cov=$(echo "$line" | awk '{print $3}') + echo "| \`$file\` | \`$fn\` | **$cov** |" + done + echo "
" + } >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage artifact From cc3602a080259eab91fc975479673748a653676c Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Thu, 27 Aug 2026 22:04:06 +0200 Subject: [PATCH 3/9] ci: scan Windows build graph via vulncheck-windows and collapse coverage summary to single-pass awk --- .github/workflows/ci.yml | 38 +++++++++++++++++++++++--------------- Makefile | 13 ++++++++++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 726306fdb..5549696a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,21 +75,26 @@ jobs: shell: bash run: | if [ -f coverage.out ]; then - total_cov=$(go tool cover -func=coverage.out | grep 'total:' | awk '{print $3}') + go tool cover -func=coverage.out | awk ' + BEGIN { + total = "unknown" + rows = "" + } + $1 == "total:" { + total = $3 + next + } { - echo "### 📊 Test Coverage Summary: **${total_cov}**" - echo "
Coverage per function" - echo "" - echo "| File | Function | Coverage |" - echo "| :--- | :--- | :--- |" - go tool cover -func=coverage.out | grep -v 'total:' | while read -r line; do - file=$(echo "$line" | awk '{print $1}') - fn=$(echo "$line" | awk '{print $2}') - cov=$(echo "$line" | awk '{print $3}') - echo "| \`$file\` | \`$fn\` | **$cov** |" - done - echo "
" - } >> "$GITHUB_STEP_SUMMARY" + rows = rows sprintf("| `%s` | `%s` | **%s** |\n", $1, $2, $3) + } + END { + print "### 📊 Test Coverage Summary: **" total "**" + print "
Coverage per function\n" + print "| File | Function | Coverage |" + print "| :--- | :--- | :--- |" + printf "%s", rows + print "
" + }' >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage artifact @@ -190,5 +195,8 @@ jobs: go-version-file: go.mod cache: true - - name: govulncheck + - name: govulncheck (linux) run: make vulncheck + + - name: govulncheck (windows) + run: make vulncheck-windows diff --git a/Makefile b/Makefile index 6ef9cc560..eeb7972a5 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,9 @@ DEADCODE_VERSION := v0.46.0 GOLANGCI_LINT_VERSION := v2.12.2 GOVULNCHECK_VERSION := v1.3.0 -.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck tidy clean baseline help +export GOWORK ?= off + +.PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help # Build the main CLI binary into ./zero. build: @@ -44,7 +46,8 @@ lint: fmt-check vet # the possibly stale Go toolchain or consulting a multi-module GOWORK. git grep # works with both POSIX shells and cmd.exe, including GNU Make 3.81 on macOS. # The target-specific export is shell-independent. -lint-static deadcode vulncheck: export GOTOOLCHAIN = $(GO_TOOLCHAIN) +lint-static deadcode vulncheck vulncheck-windows: export GOTOOLCHAIN = $(GO_TOOLCHAIN) +lint-static deadcode vulncheck vulncheck-windows: export GOWORK = off lint-static: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run --enable-only unused,ineffassign,staticcheck ./... @@ -55,6 +58,10 @@ deadcode: vulncheck: go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... +vulncheck-windows: + go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + GOOS=windows govulncheck ./... + tidy: go mod tidy @@ -77,4 +84,4 @@ baseline: build --output internal/perfbench/reports/baseline.json help: - @echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, tidy, clean, baseline" + @echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, lint-static, deadcode, vulncheck, vulncheck-windows, tidy, clean, baseline" From a9ac53c5d721ad8d43d19791c1fbb079bf8c21f5 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Fri, 28 Aug 2026 23:53:26 +0200 Subject: [PATCH 4/9] ci: invoke the pinned Windows govulncheck via a local GOBIN go install writes the binary to GOBIN, which is not necessarily on PATH. Install into .cache/gobin and run that executable. GOOS=windows applies only to the scan so the scanner stays a host binary. --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index eeb7972a5..45f9aa9b2 100644 --- a/Makefile +++ b/Makefile @@ -59,8 +59,9 @@ vulncheck: go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... vulncheck-windows: - go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) - GOOS=windows govulncheck ./... + mkdir -p $(CURDIR)/.cache/gobin + GOBIN=$(CURDIR)/.cache/gobin go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + GOOS=windows $(CURDIR)/.cache/gobin/govulncheck ./... tidy: go mod tidy From 629ae66fbc044335c6229afc5a49b43a3c231d8b Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:11:16 +0200 Subject: [PATCH 5/9] ci: declare contents:read on the action smoke workflow Pull-request workflows inherit the default GITHUB_TOKEN unless they set an explicit permission block. The smoke workflow is now covered by the same least-privilege policy as ci.yml. --- .github/workflows/zero-action-smoke.yml | 3 ++ .../installtest/workflow_permissions_test.go | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 internal/installtest/workflow_permissions_test.go diff --git a/.github/workflows/zero-action-smoke.yml b/.github/workflows/zero-action-smoke.yml index 73ae8f657..33d89ba8f 100644 --- a/.github/workflows/zero-action-smoke.yml +++ b/.github/workflows/zero-action-smoke.yml @@ -14,6 +14,9 @@ on: - docs/GITHUB_ACTION.md - .github/workflows/zero-action-smoke.yml +permissions: + contents: read + jobs: validate: name: Validate action.yml diff --git a/internal/installtest/workflow_permissions_test.go b/internal/installtest/workflow_permissions_test.go new file mode 100644 index 000000000..e55a1d56a --- /dev/null +++ b/internal/installtest/workflow_permissions_test.go @@ -0,0 +1,33 @@ +package installtest + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) { + root := filepath.Join(repoRoot(t), ".github", "workflows") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + var missing []string + for _, e := range entries { + if e.IsDir() || !(strings.HasSuffix(e.Name(), ".yml") || strings.HasSuffix(e.Name(), ".yaml")) { + continue + } + rel := filepath.Join(".github", "workflows", e.Name()) + body := readRepoText(t, rel) + if !strings.Contains(body, "\npull_request:") && !strings.Contains(body, "\n pull_request:") { + continue + } + if !strings.Contains(body, "permissions:") || !strings.Contains(body, "contents: read") { + missing = append(missing, rel) + } + } + if len(missing) > 0 { + t.Fatalf("pull_request workflows missing permissions contents: read: %s", strings.Join(missing, ", ")) + } +} From 309a5f87c8c4b0100724f4fb21b97f8ea82ea7eb Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 01:41:30 +0200 Subject: [PATCH 6/9] ci: parse workflow permissions structurally and quote the Windows scanner path The pull-request token check now ignores commented lookalikes and accepts an inline on: [pull_request] trigger. vulncheck-windows keeps the cache path as one shell word. --- Makefile | 6 +- .../installtest/workflow_permissions_test.go | 128 +++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 45f9aa9b2..48c8bdbb9 100644 --- a/Makefile +++ b/Makefile @@ -59,9 +59,9 @@ vulncheck: go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... vulncheck-windows: - mkdir -p $(CURDIR)/.cache/gobin - GOBIN=$(CURDIR)/.cache/gobin go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) - GOOS=windows $(CURDIR)/.cache/gobin/govulncheck ./... + mkdir -p "$(CURDIR)/.cache/gobin" + GOBIN="$(CURDIR)/.cache/gobin" go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + GOOS=windows "$(CURDIR)/.cache/gobin/govulncheck" ./... tidy: go mod tidy diff --git a/internal/installtest/workflow_permissions_test.go b/internal/installtest/workflow_permissions_test.go index e55a1d56a..335c80960 100644 --- a/internal/installtest/workflow_permissions_test.go +++ b/internal/installtest/workflow_permissions_test.go @@ -20,10 +20,10 @@ func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) { } rel := filepath.Join(".github", "workflows", e.Name()) body := readRepoText(t, rel) - if !strings.Contains(body, "\npull_request:") && !strings.Contains(body, "\n pull_request:") { + if !workflowHasPullRequestTrigger(body) { continue } - if !strings.Contains(body, "permissions:") || !strings.Contains(body, "contents: read") { + if !workflowHasTopLevelContentsRead(body) { missing = append(missing, rel) } } @@ -31,3 +31,127 @@ func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) { t.Fatalf("pull_request workflows missing permissions contents: read: %s", strings.Join(missing, ", ")) } } + +func TestWorkflowPermissionParsing(t *testing.T) { + tests := []struct { + name string + yml string + wantPR bool + wantPerm bool + }{ + { + name: "mapping trigger", + yml: "on:\n pull_request:\npermissions:\n contents: read\n", + wantPR: true, + wantPerm: true, + }, + { + name: "inline trigger list", + yml: "on: [pull_request]\npermissions:\n contents: read\n", + wantPR: true, + wantPerm: true, + }, + { + name: "commented permissions ignored", + yml: "on:\n pull_request:\n# permissions:\n# contents: read\n", + wantPR: true, + wantPerm: false, + }, + { + name: "nested script text is not a trigger", + yml: "on:\n push:\njobs:\n x:\n steps:\n - run: echo pull_request: permissions: contents: read\n", + wantPR: false, + wantPerm: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := workflowHasPullRequestTrigger(tc.yml); got != tc.wantPR { + t.Fatalf("trigger = %v, want %v", got, tc.wantPR) + } + if got := workflowHasTopLevelContentsRead(tc.yml); got != tc.wantPerm { + t.Fatalf("permissions = %v, want %v", got, tc.wantPerm) + } + }) + } +} + +func TestVulncheckWindowsQuotesGobinPath(t *testing.T) { + body := readRepoText(t, "Makefile") + for _, want := range []string{ + `mkdir -p "$(CURDIR)/.cache/gobin"`, + `GOBIN="$(CURDIR)/.cache/gobin"`, + `"$(CURDIR)/.cache/gobin/govulncheck"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("vulncheck-windows missing quoted path %s", want) + } + } +} + +func workflowHasPullRequestTrigger(body string) bool { + inOn := false + for _, line := range yamlActiveLines(body) { + indent := yamlIndent(line) + trim := strings.TrimSpace(line) + if indent == 0 && trim != "" && !strings.HasPrefix(trim, "-") { + key, rest, ok := strings.Cut(trim, ":") + if !ok { + inOn = false + continue + } + inOn = key == "on" + if inOn && strings.Contains(rest, "pull_request") { + return true + } + continue + } + if inOn && strings.Contains(trim, "pull_request") { + return true + } + } + return false +} + +func workflowHasTopLevelContentsRead(body string) bool { + inPerm := false + for _, line := range yamlActiveLines(body) { + indent := yamlIndent(line) + trim := strings.TrimSpace(line) + if indent == 0 && trim != "" && !strings.HasPrefix(trim, "-") { + key, rest, ok := strings.Cut(trim, ":") + inPerm = ok && key == "permissions" + if inPerm && strings.Contains(rest, "contents: read") { + return true + } + continue + } + if inPerm && trim == "contents: read" { + return true + } + } + return false +} + +func yamlActiveLines(body string) []string { + var out []string + for _, line := range strings.Split(body, "\n") { + trim := strings.TrimSpace(line) + if trim == "" || strings.HasPrefix(trim, "#") { + continue + } + out = append(out, line) + } + return out +} + +func yamlIndent(line string) int { + n := 0 + for _, r := range line { + if r != ' ' && r != '\t' { + break + } + n++ + } + return n +} From 3f18ec2cda5f83b5008f19f331da3e350f313614 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sat, 29 Aug 2026 10:15:00 +0200 Subject: [PATCH 7/9] ci: drop -race from this PR so issue 939 stays with PR 940 Coverage profile remains. The dedicated race-detector job is owned by the open race PR. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5549696a5..52836420a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,9 +66,9 @@ jobs: go-version-file: go.mod cache: true - - name: Test with race detector & coverage + - name: Test with coverage run: | - go test -race -count=1 -coverprofile=coverage.out -covermode=atomic ./... + go test -count=1 -coverprofile=coverage.out -covermode=atomic ./... - name: Publish coverage summary if: always() From f14fdb4ab919f48bd2480c9e8701121c0a8bab41 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 17:01:52 +0200 Subject: [PATCH 8/9] ci: parse workflow permissions as YAML and scope GOWORK=off --- .github/workflows/ci.yml | 4 +- Makefile | 2 - go.mod | 1 + go.sum | 4 + .../installtest/workflow_permissions_test.go | 133 +++++++++++------- 5 files changed, 87 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52836420a..f15e45f23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: run: make lint-static test: - name: Unit Tests & Race Detector + name: Unit Tests & Coverage runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -132,7 +132,7 @@ jobs: go-version-file: go.mod cache: true - - name: Test (Fast Non-Race) + - name: Test run: go test ./... - name: Build binary diff --git a/Makefile b/Makefile index 48c8bdbb9..ad9ed6e5f 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,6 @@ DEADCODE_VERSION := v0.46.0 GOLANGCI_LINT_VERSION := v2.12.2 GOVULNCHECK_VERSION := v1.3.0 -export GOWORK ?= off - .PHONY: build build-all test test-race vet fmt fmt-check lint lint-static deadcode vulncheck vulncheck-windows tidy clean baseline help # Build the main CLI binary into ./zero. diff --git a/go.mod b/go.mod index 8d06eefc8..18abcd401 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 golang.org/x/image v0.45.0 golang.org/x/sys v0.47.0 + gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.13.1 ) diff --git a/go.sum b/go.sum index d4601485b..cc3f13239 100644 --- a/go.sum +++ b/go.sum @@ -70,5 +70,9 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/installtest/workflow_permissions_test.go b/internal/installtest/workflow_permissions_test.go index 335c80960..a3e5a061c 100644 --- a/internal/installtest/workflow_permissions_test.go +++ b/internal/installtest/workflow_permissions_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) { @@ -20,10 +22,15 @@ func TestPullRequestWorkflowsDeclareContentsRead(t *testing.T) { } rel := filepath.Join(".github", "workflows", e.Name()) body := readRepoText(t, rel) - if !workflowHasPullRequestTrigger(body) { + doc, err := parseWorkflow(body) + if err != nil { + t.Errorf("%s: yaml: %v", rel, err) + continue + } + if !workflowOnHasPullRequest(doc.On) { continue } - if !workflowHasTopLevelContentsRead(body) { + if !workflowPermissionsContentsRead(doc.Permissions) { missing = append(missing, rel) } } @@ -63,6 +70,36 @@ func TestWorkflowPermissionParsing(t *testing.T) { wantPR: false, wantPerm: false, }, + { + name: "inline comment does not fake contents read", + yml: "on: [pull_request]\npermissions: write-all # contents: read\n", + wantPR: true, + wantPerm: false, + }, + { + name: "empty permissions mapping", + yml: "on: [pull_request]\npermissions: {}\n", + wantPR: true, + wantPerm: false, + }, + { + name: "write-all is not contents read", + yml: "on: [pull_request]\npermissions: write-all\n", + wantPR: true, + wantPerm: false, + }, + { + name: "quoted on and permissions keys", + yml: "\"on\": [pull_request]\n\"permissions\":\n \"contents\": read\n", + wantPR: true, + wantPerm: true, + }, + { + name: "nested job permissions are not top-level", + yml: "on:\n pull_request:\njobs:\n x:\n permissions:\n contents: read\n", + wantPR: true, + wantPerm: false, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -89,69 +126,59 @@ func TestVulncheckWindowsQuotesGobinPath(t *testing.T) { } } +type workflowFile struct { + On any `yaml:"on"` + Permissions any `yaml:"permissions"` +} + +func parseWorkflow(body string) (workflowFile, error) { + var doc workflowFile + err := yaml.Unmarshal([]byte(body), &doc) + return doc, err +} + func workflowHasPullRequestTrigger(body string) bool { - inOn := false - for _, line := range yamlActiveLines(body) { - indent := yamlIndent(line) - trim := strings.TrimSpace(line) - if indent == 0 && trim != "" && !strings.HasPrefix(trim, "-") { - key, rest, ok := strings.Cut(trim, ":") - if !ok { - inOn = false - continue - } - inOn = key == "on" - if inOn && strings.Contains(rest, "pull_request") { - return true - } - continue - } - if inOn && strings.Contains(trim, "pull_request") { - return true - } + doc, err := parseWorkflow(body) + if err != nil { + return false } - return false + return workflowOnHasPullRequest(doc.On) } func workflowHasTopLevelContentsRead(body string) bool { - inPerm := false - for _, line := range yamlActiveLines(body) { - indent := yamlIndent(line) - trim := strings.TrimSpace(line) - if indent == 0 && trim != "" && !strings.HasPrefix(trim, "-") { - key, rest, ok := strings.Cut(trim, ":") - inPerm = ok && key == "permissions" - if inPerm && strings.Contains(rest, "contents: read") { + doc, err := parseWorkflow(body) + if err != nil { + return false + } + return workflowPermissionsContentsRead(doc.Permissions) +} + +func workflowOnHasPullRequest(on any) bool { + switch v := on.(type) { + case string: + return v == "pull_request" + case []any: + for _, item := range v { + if workflowOnHasPullRequest(item) { return true } - continue - } - if inPerm && trim == "contents: read" { - return true } + case map[string]any: + _, ok := v["pull_request"] + return ok } return false } -func yamlActiveLines(body string) []string { - var out []string - for _, line := range strings.Split(body, "\n") { - trim := strings.TrimSpace(line) - if trim == "" || strings.HasPrefix(trim, "#") { - continue - } - out = append(out, line) +func workflowPermissionsContentsRead(perm any) bool { + m, ok := perm.(map[string]any) + if !ok { + return false } - return out -} - -func yamlIndent(line string) int { - n := 0 - for _, r := range line { - if r != ' ' && r != '\t' { - break - } - n++ + contents, ok := m["contents"] + if !ok { + return false } - return n + s, ok := contents.(string) + return ok && s == "read" } From 9c89b037b58b87c7e2fc2745e909e44eea8f4921 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Sun, 30 Aug 2026 17:04:09 +0200 Subject: [PATCH 9/9] ci: label the non-race smoke test explicitly --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f15e45f23..086e3d4ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: go-version-file: go.mod cache: true - - name: Test + - name: Test (Fast Non-Race) run: go test ./... - name: Build binary