diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f46eb674..4d1383fbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,8 @@ registry/[namespace]/ For example: `/registry/your-username/modules/` and `/registry/your-username/templates/`. If a namespace is taken, choose a different unique namespace, but you can still use any display name on the Registry website. +Namespace directory names must be **lowercase**, may only contain letters, numbers, and hyphens, and must start and end with a letter or number. The namespace becomes part of the case-sensitive module source path (`registry.coder.com/[namespace]/[module]/coder`), so lowercase keeps those paths predictable. A few namespaces created before this rule keep their mixed-case names because renaming them would break existing module references. + ### Images and Icons - **Namespace avatars**: Must be named `avatar.png` or `avatar.svg` in `/registry/[namespace]/.images/` diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index 984218869..09782d376 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -16,6 +16,34 @@ var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images" // validNameRe validates that names contain only alphanumeric characters and hyphens var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`) +// validNamespaceRe validates that a namespace directory name is lowercase, contains only alphanumeric characters and +// hyphens, and starts and ends with an alphanumeric character. A namespace becomes part of the case-sensitive module +// source path (registry.coder.com///coder), so mixed case produces paths that are inconsistent and +// easy to mistype. +var validNamespaceRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +// grandfatheredMixedCaseNamespaces lists the namespaces that existed before the lowercase rule and cannot be renamed +// yet. Both have published modules whose source paths are case-sensitive, and the registry server has no alias +// mechanism for the old path. They are exempt until that migration happens. Do not add new entries. +var grandfatheredMixedCaseNamespaces = []string{ + "AJ0070", + "BenraouaneSoufiane", +} + +// validateNamespaceName validates the directory name of a single namespace under /registry. +func validateNamespaceName(namespaceName string) error { + if slices.Contains(grandfatheredMixedCaseNamespaces, namespaceName) { + return nil + } + if validNamespaceRe.MatchString(namespaceName) { + return nil + } + // If the lowercased name is valid, case was the only problem, so point at the name to use. + if lowercased := strings.ToLower(namespaceName); validNamespaceRe.MatchString(lowercased) { + return xerrors.Errorf("namespace name must be lowercase (use %q)", lowercased) + } + return xerrors.New("namespace name must contain only lowercase alphanumeric characters and hyphens, starting and ending with an alphanumeric character") +} // validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all // expected file conventions @@ -91,8 +119,8 @@ func validateRegistryDirectory() []error { } // Validate namespace name - if !validNameRe.MatchString(nDir.Name()) { - allErrs = append(allErrs, xerrors.Errorf("%q: namespace name contains invalid characters (only alphanumeric characters and hyphens are allowed)", namespacePath)) + if err := validateNamespaceName(nDir.Name()); err != nil { + allErrs = append(allErrs, xerrors.Errorf("%q: %w", namespacePath, err)) continue } diff --git a/cmd/readmevalidation/repostructure_test.go b/cmd/readmevalidation/repostructure_test.go new file mode 100644 index 000000000..953ddd93f --- /dev/null +++ b/cmd/readmevalidation/repostructure_test.go @@ -0,0 +1,55 @@ +package main + +import "testing" + +func TestValidateNamespaceName(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + shouldPass bool + }{ + // Lowercase namespaces are always allowed. + {name: "coder", shouldPass: true}, + {name: "coder-labs", shouldPass: true}, + {name: "aj0070", shouldPass: true}, + {name: "user123", shouldPass: true}, + {name: "excellencedev", shouldPass: true}, + {name: "iamtaochen", shouldPass: true}, + + // Mixed-case namespaces that predate the rule stay allowed. + {name: "AJ0070", shouldPass: true}, + {name: "BenraouaneSoufiane", shouldPass: true}, + + // New mixed-case namespaces are rejected. + {name: "Coder", shouldPass: false}, + {name: "CoderLabs", shouldPass: false}, + {name: "coder-Labs", shouldPass: false}, + {name: "Excellencedev", shouldPass: false}, + {name: "IamTaoChen", shouldPass: false}, + + // Other invalid names are still rejected. + {name: "", shouldPass: false}, + {name: "-coder", shouldPass: false}, + {name: "coder-", shouldPass: false}, + {name: "coder_labs", shouldPass: false}, + {name: "coder labs", shouldPass: false}, + {name: "coder.labs", shouldPass: false}, + {name: "Coder_Labs", shouldPass: false}, + {name: "-Coder", shouldPass: false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateNamespaceName(tc.name) + if tc.shouldPass && err != nil { + t.Errorf("expected %q to be a valid namespace name, got error: %v", tc.name, err) + } + if !tc.shouldPass && err == nil { + t.Errorf("expected %q to be an invalid namespace name, got no error", tc.name) + } + }) + } +} diff --git a/registry/Excellencedev/.images/avatar.png b/registry/excellencedev/.images/avatar.png similarity index 100% rename from registry/Excellencedev/.images/avatar.png rename to registry/excellencedev/.images/avatar.png diff --git a/registry/Excellencedev/README.md b/registry/excellencedev/README.md similarity index 100% rename from registry/Excellencedev/README.md rename to registry/excellencedev/README.md diff --git a/registry/Excellencedev/templates/hetzner-linux/README.md b/registry/excellencedev/templates/hetzner-linux/README.md similarity index 100% rename from registry/Excellencedev/templates/hetzner-linux/README.md rename to registry/excellencedev/templates/hetzner-linux/README.md diff --git a/registry/Excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl b/registry/excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl similarity index 100% rename from registry/Excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl rename to registry/excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl diff --git a/registry/Excellencedev/templates/hetzner-linux/main.tf b/registry/excellencedev/templates/hetzner-linux/main.tf similarity index 100% rename from registry/Excellencedev/templates/hetzner-linux/main.tf rename to registry/excellencedev/templates/hetzner-linux/main.tf diff --git a/registry/IamTaoChen/.images/avatar.png b/registry/iamtaochen/.images/avatar.png similarity index 100% rename from registry/IamTaoChen/.images/avatar.png rename to registry/iamtaochen/.images/avatar.png diff --git a/registry/IamTaoChen/README.md b/registry/iamtaochen/README.md similarity index 100% rename from registry/IamTaoChen/README.md rename to registry/iamtaochen/README.md diff --git a/registry/IamTaoChen/templates/ssh-linux/README.md b/registry/iamtaochen/templates/ssh-linux/README.md similarity index 100% rename from registry/IamTaoChen/templates/ssh-linux/README.md rename to registry/iamtaochen/templates/ssh-linux/README.md diff --git a/registry/IamTaoChen/templates/ssh-linux/main.tf b/registry/iamtaochen/templates/ssh-linux/main.tf similarity index 100% rename from registry/IamTaoChen/templates/ssh-linux/main.tf rename to registry/iamtaochen/templates/ssh-linux/main.tf