diff --git a/cmd/readmevalidation/codermodules.go b/cmd/readmevalidation/codermodules.go index 4c9d854d0..005a98ee8 100644 --- a/cmd/readmevalidation/codermodules.go +++ b/cmd/readmevalidation/codermodules.go @@ -94,6 +94,9 @@ func validateCoderModuleReadme(rm coderResourceReadme) []error { for _, err := range validateCoderModuleReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + for _, err := range validateResourceGfmAlerts(rm.body) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { errs = append(errs, fmErrs...) } diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index f7237a368..818d73c8c 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "errors" "net/url" "os" @@ -16,11 +17,16 @@ import ( var ( supportedResourceTypes = []string{"modules", "templates"} operatingSystems = []string{"windows", "macos", "linux"} + gfmAlertTypes = []string{"NOTE", "IMPORTANT", "CAUTION", "WARNING", "TIP"} // TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but // realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's // structured. Just validating whether it *can* be parsed as Terraform would be a big improvement. terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`) + + // Matches the format "> [!INFO]". Deliberately using a broad pattern to catch formatting issues that can mess up + // the renderer for the Registry website + gfmAlertRegex = regexp.MustCompile(`^>(\s*)\[!(\w+)\](\s*)(.*)`) ) type coderResourceFrontmatter struct { @@ -277,3 +283,73 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) { } return allReadmeFiles, nil } + +func validateResourceGfmAlerts(readmeBody string) []error { + trimmed := strings.TrimSpace(readmeBody) + if trimmed == "" { + return nil + } + + var errs []error + var sourceLine string + isInsideGfmQuotes := false + isInsideCodeBlock := false + + lineScanner := bufio.NewScanner(strings.NewReader(trimmed)) + for lineScanner.Scan() { + sourceLine = lineScanner.Text() + + if strings.HasPrefix(sourceLine, "```") { + isInsideCodeBlock = !isInsideCodeBlock + continue + } + if isInsideCodeBlock { + continue + } + + isInsideGfmQuotes = isInsideGfmQuotes && strings.HasPrefix(sourceLine, "> ") + + currentMatch := gfmAlertRegex.FindStringSubmatch(sourceLine) + if currentMatch == nil { + continue + } + + // Nested GFM alerts is such a weird mistake that it's probably not really safe to keep trying to process the + // rest of the content, so this will prevent any other validations from happening for the given line + if isInsideGfmQuotes { + errs = append(errs, errors.New("registry does not support nested GFM alerts")) + continue + } + + leadingWhitespace := currentMatch[1] + if len(leadingWhitespace) != 1 { + errs = append(errs, errors.New("GFM alerts must have one space between the '>' and the start of the GFM brackets")) + } + isInsideGfmQuotes = true + + alertHeader := currentMatch[2] + upperHeader := strings.ToUpper(alertHeader) + if !slices.Contains(gfmAlertTypes, upperHeader) { + errs = append(errs, xerrors.Errorf("GFM alert type %q is not supported", alertHeader)) + } + if alertHeader != upperHeader { + errs = append(errs, xerrors.Errorf("GFM alerts must be in all caps")) + } + + trailingWhitespace := currentMatch[3] + if trailingWhitespace != "" { + errs = append(errs, xerrors.Errorf("GFM alerts must not have any trailing whitespace after the closing bracket")) + } + + extraContent := currentMatch[4] + if extraContent != "" { + errs = append(errs, xerrors.Errorf("GFM alerts must not have any extra content on the same line")) + } + } + + if gfmAlertRegex.Match([]byte(sourceLine)) { + errs = append(errs, xerrors.Errorf("README has an incomplete GFM alert at the end of the file")) + } + + return errs +} diff --git a/cmd/readmevalidation/codertemplates.go b/cmd/readmevalidation/codertemplates.go index df0c6c0b6..b076ad8af 100644 --- a/cmd/readmevalidation/codertemplates.go +++ b/cmd/readmevalidation/codertemplates.go @@ -70,6 +70,9 @@ func validateCoderTemplateReadme(rm coderResourceReadme) []error { for _, err := range validateCoderTemplateReadmeBody(rm.body) { errs = append(errs, addFilePathToError(rm.filePath, err)) } + for _, err := range validateResourceGfmAlerts(rm.body) { + errs = append(errs, addFilePathToError(rm.filePath, err)) + } if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 { errs = append(errs, fmErrs...) } diff --git a/registry/coder/templates/azure-linux/README.md b/registry/coder/templates/azure-linux/README.md index 89e910d3a..33d771ede 100644 --- a/registry/coder/templates/azure-linux/README.md +++ b/registry/coder/templates/azure-linux/README.md @@ -35,7 +35,7 @@ This means, when the workspace restarts, any tools or files outside of the home ### Persistent VM -> [!IMPORTANT] +> [!IMPORTANT] > This approach requires the [`az` CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli#install) to be present in the PATH of your Coder Provisioner. > You will have to do this installation manually as it is not included in our official images. diff --git a/registry/coder/templates/azure-windows/README.md b/registry/coder/templates/azure-windows/README.md index cf6b1a86c..135980611 100644 --- a/registry/coder/templates/azure-windows/README.md +++ b/registry/coder/templates/azure-windows/README.md @@ -35,7 +35,7 @@ This means, when the workspace restarts, any tools or files outside of the data ### Persistent VM -> [!IMPORTANT] +> [!IMPORTANT] > This approach requires the [`az` CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli#install) to be present in the PATH of your Coder Provisioner. > You will have to do this installation manually as it is not included in our official images.