diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 53b912bf..eb3cf8b3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -63,8 +63,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.23.2" - - name: Validate contributors + go-version: "1.25.0" + - name: Validate Reademde run: go build ./cmd/readmevalidation && ./readmevalidation - name: Remove build file artifact run: rm ./readmevalidation diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index 005a98ee..fe33a7ca 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -3,11 +3,87 @@ package main import ( "bufio" "context" + "path/filepath" + "regexp" "strings" "golang.org/x/xerrors" ) +var ( + terraformSourceRe = regexp.MustCompile(`^\s*source\s*=\s*"([^"]+)"`) +) + +func normalizeModuleName(name string) string { + // Normalize module names by replacing hyphens with underscores for comparison + // since Terraform allows both but directory names typically use hyphens + return strings.ReplaceAll(name, "-", "_") +} + +func extractNamespaceAndModuleFromPath(filePath string) (string, string, error) { + // Expected path format: registry//modules//README.md + parts := strings.Split(filepath.Clean(filePath), string(filepath.Separator)) + if len(parts) < 5 || parts[0] != "registry" || parts[2] != "modules" || parts[4] != "README.md" { + return "", "", xerrors.Errorf("invalid module path format: %s", filePath) + } + namespace := parts[1] + moduleName := parts[3] + return namespace, moduleName, nil +} + +func validateModuleSourceURL(body string, filePath string) []error { + var errs []error + + namespace, moduleName, err := extractNamespaceAndModuleFromPath(filePath) + if err != nil { + return []error{err} + } + + expectedSource := "registry.coder.com/" + namespace + "/" + moduleName + "/coder" + + trimmed := strings.TrimSpace(body) + foundCorrectSource := false + isInsideTerraform := false + firstTerraformBlock := true + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + nextLine := lineScanner.Text() + + if strings.HasPrefix(nextLine, "```") { + if strings.HasPrefix(nextLine, "```tf") && firstTerraformBlock { + isInsideTerraform = true + firstTerraformBlock = false + } else if isInsideTerraform { + // End of first terraform block + break + } + continue + } + + if isInsideTerraform { + // Check for any source line in the first terraform block + if matches := terraformSourceRe.FindStringSubmatch(nextLine); matches != nil { + actualSource := matches[1] + if actualSource == expectedSource { + foundCorrectSource = true + break + } else if strings.HasPrefix(actualSource, "registry.coder.com/") && strings.Contains(actualSource, "/"+moduleName+"/coder") { + // Found source for this module but with wrong namespace/format + errs = append(errs, xerrors.Errorf("incorrect source URL format: found %q, expected %q", actualSource, expectedSource)) + return errs + } + } + } + } + + if !foundCorrectSource { + errs = append(errs, xerrors.Errorf("did not find correct source URL %q in first Terraform code block", expectedSource)) + } + + return errs +} + func validateCoderModuleReadmeBody(body string) []error { var errs []error @@ -94,6 +170,9 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error { for _, err := range validateCoderModuleReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + for _, err := range validateModuleSourceURL(rm.body, rm.filePath) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } for _, err := range validateResourceGfmAlerts(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } diff --git a/cmd/readmevalidation/codermodules_test.go b/cmd/readmevalidation/codermodules_test.go index 194a861e..ef34e906 100644 --- a/cmd/readmevalidation/codermodules_test.go +++ b/cmd/readmevalidation/codermodules_test.go @@ -20,3 +20,86 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) { } }) } + +func TestValidateModuleSourceURL(t *testing.T) { + t.Parallel() + + t.Run("Valid source URL format", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 0 { + t.Errorf("Expected no errors, got: %v", errs) + } + }) + + t.Run("Invalid source URL format - wrong namespace", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test-module\" {\n source = \"registry.coder.com/wrong-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "incorrect source URL format") { + t.Errorf("Expected source URL format error, got: %s", errs[0].Error()) + } + }) + + t.Run("Missing source URL", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"other-module\" {\n source = \"registry.coder.com/other/other-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "did not find correct source URL") { + t.Errorf("Expected missing source URL error, got: %s", errs[0].Error()) + } + }) + + t.Run("Module name with hyphens vs underscores", func(t *testing.T) { + t.Parallel() + + body := "# Test Module\n\n```tf\nmodule \"test_module\" {\n source = \"registry.coder.com/test-namespace/test-module/coder\"\n version = \"1.0.0\"\n agent_id = coder_agent.example.id\n}\n```\n" + filePath := "registry/test-namespace/modules/test-module/README.md" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 0 { + t.Errorf("Expected no errors for hyphen/underscore variation, got: %v", errs) + } + }) + + t.Run("Invalid file path format", func(t *testing.T) { + t.Parallel() + + body := "# Test Module" + filePath := "invalid/path/format" + errs := validateModuleSourceURL(body, filePath) + if len(errs) != 1 { + t.Errorf("Expected 1 error, got %d: %v", len(errs), errs) + } + if len(errs) > 0 && !contains(errs[0].Error(), "invalid module path format") { + t.Errorf("Expected path format error, got: %s", errs[0].Error()) + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) && + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + indexOfSubstring(s, substr) >= 0))) +} + +func indexOfSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/package.json b/package.json index d441e6ac..1665a2bf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff", "terraform-validate": "./scripts/terraform_validate.sh", "test": "./scripts/terraform_test_all.sh", - "update-version": "./update-version.sh" + "update-version": "./update-version.sh", + "validate-readme": "go build ./cmd/readmevalidation && ./readmevalidation" }, "devDependencies": { "@types/bun": "^1.2.21",