diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e6a24bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [ develop, main, master ] + pull_request: + branches: [ '**' ] + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Print Go version + run: go version + + - name: Install ripgrep (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y ripgrep + + - name: Install ripgrep (macOS) + if: runner.os == 'macOS' + run: | + brew update + brew install ripgrep + + - name: Install make and ripgrep (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + choco install -y make ripgrep + echo "make version:"; make --version || true + echo "rg version:"; rg --version || true + + - name: Print Go env + run: go env + + - name: Tidy + shell: bash + run: make tidy + + - name: lint (includes check-go-version) + shell: bash + run: | + set -o pipefail + make lint 2>&1 | tee lint.log + + - name: Assert lint order (check-go-version before golangci-lint) + shell: bash + run: | + test -f lint.log || { echo "lint.log missing"; exit 1; } + L_CHECK=$(rg -n "^check-go-version: OK" -N lint.log | head -1 | cut -d: -f1) + L_GCL=$(rg -n "^golangci-lint version" -N lint.log | head -1 | cut -d: -f1) + if [ -z "$L_CHECK" ]; then echo "Missing 'check-go-version: OK' line in lint.log"; exit 1; fi + if [ -z "$L_GCL" ]; then echo "Missing 'golangci-lint version' line in lint.log"; exit 1; fi + if [ "$L_CHECK" -ge "$L_GCL" ]; then + echo "Ordering incorrect: 'check-go-version: OK' occurs at line $L_CHECK, after golangci-lint version at line $L_GCL"; exit 1; + fi + echo "Lint order OK: check-go-version runs before golangci-lint" + + - name: Upload lint.log artifact + uses: actions/upload-artifact@v4 + with: + name: lint-${{ matrix.os }} + path: lint.log + if-no-files-found: error + + - name: Tools path hygiene + shell: bash + run: make check-tools-paths + + - name: Verify tools manifest commands + shell: bash + run: make verify-manifest-paths + + - name: Test + shell: bash + run: make test + + - name: Test clean-logs guard + shell: bash + run: make test-clean-logs + + - name: Build + shell: bash + run: make build + + - name: Build tools + shell: bash + run: make build-tools diff --git a/internal/ci/ci_workflow_test.go b/internal/ci/ci_workflow_test.go new file mode 100644 index 0000000..8dc3208 --- /dev/null +++ b/internal/ci/ci_workflow_test.go @@ -0,0 +1,85 @@ +package ci + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// This test asserts two things locally without requiring CI: +// 1) Makefile lint recipe runs check-go-version before invoking golangci-lint +// 2) The CI workflow includes an explicit step that verifies the ordering +func TestLintOrderLocallyAndInWorkflow(t *testing.T) { + repoRoot, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + // Assert Makefile ordering: check-go-version appears before golangci-lint + mkPath := filepath.Join(repoRoot, "..", "..", "Makefile") + mkBytes, err := os.ReadFile(mkPath) + if err != nil { + t.Fatalf("read Makefile: %v", err) + } + mk := string(mkBytes) + if !strings.Contains(mk, "lint:") { + t.Fatalf("Makefile missing 'lint:' target") + } + // Extract only the lint recipe block (the lines starting with a tab after 'lint:') + lines := strings.Split(mk, "\n") + lintIdx := -1 + for i, ln := range lines { + if strings.HasPrefix(ln, "lint:") { + lintIdx = i + break + } + } + if lintIdx < 0 { + t.Fatalf("Makefile missing lint target label") + } + var recipeLines []string + for j := lintIdx + 1; j < len(lines); j++ { + ln := lines[j] + if strings.HasPrefix(ln, "\t") { // recipe lines start with a tab + recipeLines = append(recipeLines, ln) + continue + } + // Stop when we hit the next non-recipe line (new target or blank without tab) + if strings.TrimSpace(ln) == "" { + // allow empty recipe line with tab only + if strings.HasPrefix(ln, "\t") { + recipeLines = append(recipeLines, ln) + continue + } + } + // Not a recipe line: end of recipe + break + } + recipe := strings.Join(recipeLines, "\n") + idxCheck := strings.Index(recipe, "check-go-version") + if idxCheck < 0 { + t.Fatalf("lint recipe missing 'check-go-version' invocation") + } + idxGcl := strings.Index(recipe, "golangci-lint") + if idxGcl < 0 { + t.Fatalf("lint recipe missing 'golangci-lint' invocation") + } + if !(idxCheck < idxGcl) { + t.Fatalf("expected check-go-version to run before golangci-lint inside lint recipe (idx %d < %d)", idxCheck, idxGcl) + } + + // Assert CI workflow includes the lint order assertion step + wfPath := filepath.Join(repoRoot, "..", "..", ".github", "workflows", "ci.yml") + wfBytes, err := os.ReadFile(wfPath) + if err != nil { + t.Fatalf("read ci workflow: %v", err) + } + wf := string(wfBytes) + if !strings.Contains(wf, "lint (includes check-go-version)") { + t.Fatalf("workflow missing explicit lint step name indicating check-go-version inclusion") + } + if !strings.Contains(wf, "Assert lint order (check-go-version before golangci-lint)") { + t.Fatalf("workflow missing order assertion step") + } +}