diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63b364fd..086e3d4ad 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 formatting + run: make fmt-check + + - name: Vet + run: go vet ./... - - name: Check dead code through native Windows Make - if: matrix.os == 'windows-latest' + - name: deadcode (advisory) continue-on-error: true - shell: cmd run: make deadcode - - name: Run static lint through native Windows Make - if: matrix.os == 'windows-latest' + - name: golangci-lint (advisory) continue-on-error: true - shell: cmd run: make lint-static - - name: Check formatting - if: matrix.os == 'ubuntu-latest' + test: + name: Unit Tests & Coverage + 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 coverage + run: | + go test -count=1 -coverprofile=coverage.out -covermode=atomic ./... + + - name: Publish coverage summary + if: always() shell: bash run: | - unformatted="$(gofmt -l .)" - if [ -n "$unformatted" ]; then - echo "gofmt needed on:" >&2 - echo "$unformatted" >&2 - exit 1 + if [ -f coverage.out ]; then + go tool cover -func=coverage.out | awk ' + BEGIN { + total = "unknown" + rows = "" + } + $1 == "total:" { + total = $3 + next + } + { + 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: Vet - if: matrix.os == 'ubuntu-latest' - run: go vet ./... + - 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 - - name: Test + 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,39 @@ 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 + - name: govulncheck (linux) 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 + - name: govulncheck (windows) + run: make vulncheck-windows 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/Makefile b/Makefile index 6ef9cc560..ad9ed6e5f 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ 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 +.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 +44,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 +56,11 @@ deadcode: 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" ./... + tidy: go mod tidy @@ -77,4 +83,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" 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 new file mode 100644 index 000000000..a3e5a061c --- /dev/null +++ b/internal/installtest/workflow_permissions_test.go @@ -0,0 +1,184 @@ +package installtest + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +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) + doc, err := parseWorkflow(body) + if err != nil { + t.Errorf("%s: yaml: %v", rel, err) + continue + } + if !workflowOnHasPullRequest(doc.On) { + continue + } + if !workflowPermissionsContentsRead(doc.Permissions) { + missing = append(missing, rel) + } + } + if len(missing) > 0 { + 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, + }, + { + 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) { + 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) + } + } +} + +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 { + doc, err := parseWorkflow(body) + if err != nil { + return false + } + return workflowOnHasPullRequest(doc.On) +} + +func workflowHasTopLevelContentsRead(body string) bool { + 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 + } + } + case map[string]any: + _, ok := v["pull_request"] + return ok + } + return false +} + +func workflowPermissionsContentsRead(perm any) bool { + m, ok := perm.(map[string]any) + if !ok { + return false + } + contents, ok := m["contents"] + if !ok { + return false + } + s, ok := contents.(string) + return ok && s == "read" +}