diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8c8414 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# This should be the value of the GitHub Actions actor who triggered a run. The +# CI script will inject this value from the GitHub Actions context to verify +# whether changing certain README fields is allowed. In local development, you +# can set this to your GitHub username. +CI_ACTOR= + +# This is the configurable base URL for accessing the GitHub REST API. This +# value will be injected by the CI script's Actions context, but if the value is +# not defined (either in CI or when running locally), "https://api.github.com/" +# will be used as a fallback. +GITHUB_API_URL= + +# This is the API token for the user that will be used to authenticate calls to +# the GitHub API. In CI, the value will be loaded with a token belonging to a +# Coder Registry admin to verify whether modifying certain README fields is +# allowed. In local development, you can set a token with the read:org +# permission. If the loaded token does not belong to a Coder employee, certain +# README verification steps will be skipped. +GITHUB_API_TOKEN= diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 159e8c9..e15731e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,12 @@ concurrency: jobs: validate-readme-files: runs-on: ubuntu-latest + env: + ACTOR: ${{ github.actor }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Check out code uses: actions/checkout@v4 @@ -17,9 +23,9 @@ jobs: with: go-version: "1.23.2" - name: Validate contributors - run: go build ./scripts/contributors && ./contributors + run: go build ./cmd/readmevalidation && ./readmevalidation - name: Remove build file artifact - run: rm ./contributors + run: rm ./readmevalidation test-terraform: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 6ee570e..0539b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -135,8 +135,9 @@ dist .yarn/install-state.gz .pnp.* -# Script output -/contributors +# Things needed for CI +/readmevalidation +/readmevalidation-git # Terraform files generated during testing .terraform* diff --git a/cmd/github/github.go b/cmd/github/github.go new file mode 100644 index 0000000..acc7184 --- /dev/null +++ b/cmd/github/github.go @@ -0,0 +1,169 @@ +// Package github provides utilities to make it easier to deal with various +// GitHub APIs +package github + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const defaultGithubAPIBaseRoute = "https://api.github.com/" + +// Client is a reusable REST client for making requests to the GitHub API. +// It should be instantiated via NewGithubClient +type Client struct { + baseURL string + token string + httpClient http.Client +} + +// ClientInit is used to instantiate a new client. If the value of BaseURL is +// not defined, a default value of "https://api.github.com/" is used instead +type ClientInit struct { + BaseURL string + APIToken string +} + +// NewClient instantiates a GitHub client. If the baseURL is +func NewClient(init ClientInit) (*Client, error) { + // Considered letting the user continue on with no token and more aggressive + // rate-limiting, but from experimentation, the non-authenticated experience + // hit the rate limits really quickly, and had a lot of restrictions + apiToken := init.APIToken + if apiToken == "" { + return nil, errors.New("API token is missing") + } + + baseURL := init.BaseURL + if baseURL == "" { + baseURL = defaultGithubAPIBaseRoute + } + + return &Client{ + baseURL: baseURL, + token: apiToken, + httpClient: http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// User represents a truncated version of the API response from Github's /user +// endpoint. +type User struct { + Login string `json:"login"` +} + +// GetUserFromToken returns the user associated with the loaded API token +func (gc *Client) GetUserFromToken() (User, error) { + req, err := http.NewRequest("GET", gc.baseURL+"user", nil) + if err != nil { + return User{}, err + } + if gc.token != "" { + req.Header.Add("Authorization", "Bearer "+gc.token) + } + + res, err := gc.httpClient.Do(req) + if err != nil { + return User{}, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusUnauthorized { + return User{}, errors.New("request is not authorized") + } + if res.StatusCode == http.StatusForbidden { + return User{}, errors.New("request is forbidden") + } + + b, err := io.ReadAll(res.Body) + if err != nil { + return User{}, err + } + + user := User{} + if err := json.Unmarshal(b, &user); err != nil { + return User{}, err + } + return user, nil +} + +// OrgStatus indicates whether a GitHub user is a member of a given organization +type OrgStatus int + +var _ fmt.Stringer = OrgStatus(0) + +const ( + // OrgStatusIndeterminate indicates when a user's organization status + // could not be determined. It is the zero value of the OrgStatus type, and + // any users with this value should be treated as completely untrusted + OrgStatusIndeterminate = iota + + // OrgStatusNonMember indicates when a user is definitely NOT part of an + // organization + OrgStatusNonMember + + // OrgStatusMember indicates when a user is a member of a Github + // organization + OrgStatusMember +) + +func (s OrgStatus) String() string { + switch s { + case OrgStatusMember: + return "Member" + case OrgStatusNonMember: + return "Non-member" + default: + return "Indeterminate" + } +} + +// GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see +// whether that member is part of the provided organization +func (gc *Client) GetUserOrgStatus(orgName string, username string) (OrgStatus, error) { + // This API endpoint is really annoying, because it's able to produce false + // negatives. Any user can be: + // 1. A public member of an organization + // 2. A private member of an organization + // 3. Not a member of an organization + // + // So if the function returns status 200, you can always trust that. But if + // it returns any 400 code, that could indicate a few things: + // 1. The user associated with the token is a member of the organization, + // and the user being checked is not. + // 2. The user associated with the token is NOT a member of the + // organization, and the member being checked is a private member. The + // token user will have no way to view the private member's status. + // 3. Neither the user being checked nor the user associated with the token + // are members of the organization. + // + // The best option to avoid false positives is to make sure that the token + // being used belongs to a member of the organization being checked. + url := fmt.Sprintf("%sorgs/%s/members/%s", gc.baseURL, orgName, username) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return OrgStatusIndeterminate, err + } + if gc.token != "" { + req.Header.Add("Authorization", "Bearer "+gc.token) + } + + res, err := gc.httpClient.Do(req) + if err != nil { + return OrgStatusIndeterminate, err + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusNoContent: + return OrgStatusMember, nil + case http.StatusNotFound: + return OrgStatusNonMember, nil + default: + return OrgStatusIndeterminate, nil + } +} diff --git a/cmd/readmevalidation/coderResources.go b/cmd/readmevalidation/coderResources.go new file mode 100644 index 0000000..57b4407 --- /dev/null +++ b/cmd/readmevalidation/coderResources.go @@ -0,0 +1,313 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "os" + "path" + "slices" + "strings" + + "coder.com/coder-registry/cmd/github" + "gopkg.in/yaml.v3" +) + +// dummyGitDirectory is the directory that a full version of the Registry will +// be cloned into during CI. The CI needs to use Git history to validate +// certain README files, and using the root branch itself (even though it's +// fully equivalent) has a risk of breaking other CI steps when switching +// branches. Better to make a full isolated copy and manipulate that. +const dummyGitDirectory = "./readmevalidation-git" + +var supportedResourceTypes = []string{"modules", "templates"} + +type coderResourceFrontmatter struct { + Description string `yaml:"description"` + IconURL string `yaml:"icon"` + DisplayName *string `yaml:"display_name"` + Verified *bool `yaml:"verified"` + Tags []string `yaml:"tags"` +} + +// coderResource represents a generic concept for a Terraform resource used to +// help create Coder workspaces. As of 2025-04-15, this encapsulates both +// Coder Modules and Coder Templates. If the newReadmeBody and newFrontmatter +// fields are nil, that represents that the file has been deleted +type coderResource struct { + resourceType string + filePath string + newReadmeBody *string + oldFrontmatter *coderResourceFrontmatter + newFrontmatter *coderResourceFrontmatter + oldIsVerified bool + newIsVerified bool +} + +func validateCoderResourceDisplayName(displayName *string) error { + if displayName == nil { + return nil + } + + if *displayName == "" { + return errors.New("if defined, display_name must not be empty string") + } + + return nil +} + +func validateCoderResourceDescription(description string) error { + if description == "" { + return errors.New("frontmatter description cannot be empty") + } + return nil +} + +func validateCoderResourceIconURL(iconURL string) []error { + problems := []error{} + + if iconURL == "" { + problems = append(problems, errors.New("icon URL cannot be empty")) + return problems + } + + isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") + if isAbsoluteURL { + if _, err := url.ParseRequestURI(iconURL); err != nil { + problems = append(problems, errors.New("absolute icon URL is not correctly formatted")) + } + if strings.Contains(iconURL, "?") { + problems = append(problems, errors.New("icon URLs cannot contain query parameters")) + } + return problems + } + + // Would normally be skittish about having relative paths like this, but it + // should be safe because we have guarantees about the structure of the + // repo, and where this logic will run + isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") || + strings.HasPrefix(iconURL, "/") || + strings.HasPrefix(iconURL, "../../../.icons") + if !isPermittedRelativeURL { + problems = append(problems, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL)) + } + + return problems +} + +func validateCoderResourceTags(tags []string) error { + if len(tags) == 0 { + return nil + } + + // All of these tags are used for the module/template filter controls in the + // Registry site. Need to make sure they can all be placed in the browser + // URL without issue + invalidTags := []string{} + for _, t := range tags { + if t != url.QueryEscape(t) { + invalidTags = append(invalidTags, t) + } + } + if len(invalidTags) != 0 { + return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", ")) + } + + return nil +} + +func validateCoderResourceVerifiedStatus(oldVerified bool, newVerified bool, actorOrgStatus github.OrgStatus) error { + // If the actor making the changes is an employee of Coder, any changes are + // assumed to be valid + if actorOrgStatus == github.OrgStatusMember { + return nil + } + + // Right now, because we collapse the omitted/nil case and false together, + // the only field transition that's allowed is if the verified statuses are + // exactly the same (which includes the field going from omitted to + // explicitly false, or vice-versa). + isPermittedChangeForNonEmployee := oldVerified == newVerified + if isPermittedChangeForNonEmployee { + return nil + } + + return fmt.Errorf("actor with status %q is not allowed to flip verified status from %t to %t", actorOrgStatus.String(), oldVerified, newVerified) +} + +// Todo: once we decide on how we want the README frontmatter to be formatted +// for the Embedded Registry work, update this function to validate that the +// correct Terraform code snippets are included in the README and are actually +// valid Terraform. Might also want to validate that each header follows proper +// hierarchy (i.e., not jumping from h1 to h3 because you think it looks nicer) +func validateCoderResourceReadmeBody(body string) error { + trimmed := strings.TrimSpace(body) + if !strings.HasPrefix(trimmed, "# ") { + return errors.New("README body must start with ATX-style h1 header (i.e., \"# \")") + } + return nil +} + +func validateCoderResourceChanges(resource coderResource, actorOrgStatus github.OrgStatus) []error { + var problems []error + + if resource.newReadmeBody != nil { + if err := validateCoderResourceReadmeBody(*resource.newReadmeBody); err != nil { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + } + + if resource.newFrontmatter != nil { + if err := validateCoderResourceDisplayName(resource.newFrontmatter.DisplayName); err != nil { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + if err := validateCoderResourceDescription(resource.newFrontmatter.Description); err != nil { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + if err := validateCoderResourceTags(resource.newFrontmatter.Tags); err != nil { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + if err := validateCoderResourceVerifiedStatus(resource.oldIsVerified, resource.newIsVerified, actorOrgStatus); err != nil { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + + for _, err := range validateCoderResourceIconURL(resource.newFrontmatter.IconURL) { + problems = append(problems, addFilePathToError(resource.filePath, err)) + } + } + + return problems +} + +func parseCoderResourceFiles(resourceType string, oldReadmeFiles []readme, newReadmeFiles []readme, actorOrgStatus github.OrgStatus) (map[string]coderResource, error) { + if !slices.Contains(supportedResourceTypes, resourceType) { + return nil, fmt.Errorf("resource type %q is not in supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", ")) + } + + var errs []error + resourcesByFilePath := map[string]coderResource{} + zipped := zipReadmes(oldReadmeFiles, newReadmeFiles) + + for filePath, z := range zipped { + resource := coderResource{ + resourceType: resourceType, + filePath: filePath, + } + + if z.new != nil { + fm, body, err := separateFrontmatter(z.new.rawText) + if err != nil { + errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err)) + } else { + resource.newReadmeBody = &body + var newFm coderResourceFrontmatter + if err := yaml.Unmarshal([]byte(fm), &newFm); err != nil { + errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err)) + } else { + resource.newFrontmatter = &newFm + if newFm.Verified != nil && *newFm.Verified { + resource.newIsVerified = true + } + } + } + } + + if z.old != nil { + fm, _, err := separateFrontmatter(z.old.rawText) + if err != nil { + errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err)) + } else { + var oldFm coderResourceFrontmatter + if err := yaml.Unmarshal([]byte(fm), &oldFm); err != nil { + errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err)) + } else { + resource.oldFrontmatter = &oldFm + if oldFm.Verified != nil && *oldFm.Verified { + resource.oldIsVerified = true + } + } + } + } + + if z.old != nil || z.new != nil { + resourcesByFilePath[filePath] = resource + } + } + + for _, r := range resourcesByFilePath { + errs = append(errs, validateCoderResourceChanges(r, actorOrgStatus)...) + } + + if len(errs) != 0 { + return nil, validationPhaseError{ + phase: validationPhaseReadmeParsing, + errors: errs, + } + } + return resourcesByFilePath, nil +} + +// Todo: because Coder Resource READMEs will have their full contents +// (frontmatter and body) rendered on the Registry site, we need to make sure +// that all image references in the body are valid, too +func validateCoderResourceRelativeUrls(map[string]coderResource) []error { + return nil +} + +func aggregateCoderResourceReadmeFiles(resourceDirectoryName string) ([]readme, error) { + if !slices.Contains(supportedResourceTypes, resourceDirectoryName) { + return nil, fmt.Errorf("%q is not a supported resource type. Must be one of [%s]", resourceDirectoryName, strings.Join(supportedResourceTypes, ", ")) + } + + registryFiles, err := os.ReadDir(rootRegistryPath) + if err != nil { + return nil, err + } + + var allReadmeFiles []readme + var problems []error + for _, f := range registryFiles { + if !f.IsDir() { + continue + } + + resourceDirPath := path.Join(rootRegistryPath, f.Name(), resourceDirectoryName) + resourceFiles, err := os.ReadDir(resourceDirPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + problems = append(problems, err) + } + continue + } + + for _, resFile := range resourceFiles { + // Not sure if we want to allow non-directories to live inside of + // main directories like /modules or /templates, but we can tighten + // things up later + if !resFile.IsDir() { + continue + } + + readmePath := path.Join(resourceDirPath, resFile.Name(), "README.md") + rawRm, err := os.ReadFile(readmePath) + if err != nil { + problems = append(problems, err) + continue + } + allReadmeFiles = append(allReadmeFiles, readme{ + filePath: readmePath, + rawText: string(rawRm), + }) + + } + } + + if len(problems) != 0 { + return nil, validationPhaseError{ + phase: validationPhaseFileLoad, + errors: problems, + } + } + + return allReadmeFiles, nil +} diff --git a/scripts/contributors/contributors.go b/cmd/readmevalidation/contributors.go similarity index 84% rename from scripts/contributors/contributors.go rename to cmd/readmevalidation/contributors.go index 02823f2..aa3676d 100644 --- a/scripts/contributors/contributors.go +++ b/cmd/readmevalidation/contributors.go @@ -1,9 +1,9 @@ package main import ( - "bufio" "errors" "fmt" + "log" "net/url" "os" "path" @@ -13,17 +13,7 @@ import ( "gopkg.in/yaml.v3" ) -const rootRegistryPath = "./registry" - -var ( - validContributorStatuses = []string{"official", "partner", "community"} - supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} -) - -type readme struct { - filePath string - rawText string -} +var validContributorStatuses = []string{"official", "partner", "community"} type contributorProfileFrontmatter struct { DisplayName string `yaml:"display_name"` @@ -44,61 +34,6 @@ type contributorProfile struct { filePath string } -var _ error = validationPhaseError{} - -type validationPhaseError struct { - phase string - errors []error -} - -func (vpe validationPhaseError) Error() string { - validationStrs := []string{} - for _, e := range vpe.errors { - validationStrs = append(validationStrs, fmt.Sprintf("- %v", e)) - } - slices.Sort(validationStrs) - - msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.phase) - msg += strings.Join(validationStrs, "\n") - msg += "\n" - - return msg -} - -func extractFrontmatter(readmeText string) (string, error) { - if readmeText == "" { - return "", errors.New("README is empty") - } - - const fence = "---" - fm := "" - fenceCount := 0 - lineScanner := bufio.NewScanner( - strings.NewReader(strings.TrimSpace(readmeText)), - ) - for lineScanner.Scan() { - nextLine := lineScanner.Text() - if fenceCount == 0 && nextLine != fence { - return "", errors.New("README does not start with frontmatter fence") - } - - if nextLine != fence { - fm += nextLine + "\n" - continue - } - - fenceCount++ - if fenceCount >= 2 { - break - } - } - - if fenceCount == 1 { - return "", errors.New("README does not have two sets of frontmatter fences") - } - return fm, nil -} - func validateContributorGithubUsername(githubUsername string) error { if githubUsername == "" { return errors.New("missing GitHub username") @@ -260,11 +195,7 @@ func validateContributorAvatarURL(avatarURL *string) []error { return problems } -func addFilePathToError(filePath string, err error) error { - return fmt.Errorf("%q: %v", filePath, err) -} - -func validateContributorYaml(yml contributorProfile) []error { +func validateContributorProfile(yml contributorProfile) []error { allProblems := []error{} if err := validateContributorGithubUsername(yml.frontmatter.GithubUsername); err != nil { @@ -297,7 +228,7 @@ func validateContributorYaml(yml contributorProfile) []error { } func parseContributorProfile(rm readme) (contributorProfile, error) { - fm, err := extractFrontmatter(rm.rawText) + fm, _, err := separateFrontmatter(rm.rawText) if err != nil { return contributorProfile{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err) } @@ -331,7 +262,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil } if len(yamlParsingErrors) != 0 { return nil, validationPhaseError{ - phase: "YAML parsing", + phase: validationPhaseReadmeParsing, errors: yamlParsingErrors, } } @@ -339,7 +270,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil employeeGithubGroups := map[string][]string{} yamlValidationErrors := []error{} for _, p := range profilesByUsername { - errors := validateContributorYaml(p) + errors := validateContributorProfile(p) if len(errors) > 0 { yamlValidationErrors = append(yamlValidationErrors, errors...) continue @@ -360,7 +291,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil } if len(yamlValidationErrors) != 0 { return nil, validationPhaseError{ - phase: "Raw YAML Validation", + phase: validationPhaseReadmeValidation, errors: yamlValidationErrors, } } @@ -379,7 +310,6 @@ func aggregateContributorReadmeFiles() ([]readme, error) { for _, e := range dirEntries { dirPath := path.Join(rootRegistryPath, e.Name()) if !e.IsDir() { - problems = append(problems, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath)) continue } @@ -397,7 +327,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) { if len(problems) != 0 { return nil, validationPhaseError{ - phase: "FileSystem reading", + phase: validationPhaseFileLoad, errors: problems, } } @@ -405,9 +335,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) { return allReadmeFiles, nil } -func validateRelativeUrls( - contributors map[string]contributorProfile, -) error { +func validateContributorRelativeUrls(contributors map[string]contributorProfile) error { // This function only validates relative avatar URLs for now, but it can be // beefed up to validate more in the future problems := []error{} @@ -440,7 +368,28 @@ func validateRelativeUrls( return nil } return validationPhaseError{ - phase: "Relative URL validation", + phase: validationPhaseAssetCrossReference, errors: problems, } } + +func validateAllContributors() error { + allReadmeFiles, err := aggregateContributorReadmeFiles() + if err != nil { + return err + } + log.Printf("Processing %d README files\n", len(allReadmeFiles)) + contributors, err := parseContributorFiles(allReadmeFiles) + log.Printf("Processed %d README files as valid contributor profiles", len(contributors)) + if err != nil { + return err + } + err = validateContributorRelativeUrls(contributors) + if err != nil { + return err + } + log.Println("All relative URLs for READMEs are valid") + log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath) + + return nil +} diff --git a/cmd/readmevalidation/errors.go b/cmd/readmevalidation/errors.go new file mode 100644 index 0000000..c60e3fc --- /dev/null +++ b/cmd/readmevalidation/errors.go @@ -0,0 +1,28 @@ +package main + +import "fmt" + +var _ error = validationPhaseError{} + +// validationPhaseError represents an error that occurred during a specific +// phase of README validation. It should be used to collect ALL validation +// errors that happened during a specific phase, rather than the first one +// encountered. +type validationPhaseError struct { + phase validationPhase + errors []error +} + +func (vpe validationPhaseError) Error() string { + msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.phase.String()) + for _, e := range vpe.errors { + msg += fmt.Sprintf("\n- %v", e) + } + msg += "\n" + + return msg +} + +func addFilePathToError(filePath string, err error) error { + return fmt.Errorf("%q: %v", filePath, err) +} diff --git a/cmd/readmevalidation/github.go b/cmd/readmevalidation/github.go new file mode 100644 index 0000000..efb08f7 --- /dev/null +++ b/cmd/readmevalidation/github.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "os" +) + +const actionsActorKey = "CI_ACTOR" + +const ( + githubAPIBaseURLKey = "GITHUB_API_URL" + githubAPITokenKey = "GITHUB_API_TOKEN" +) + +// actionsActor returns the username of the GitHub user who triggered the +// current CI run as part of GitHub Actions. It is expected that this value be +// set using a local .env file in local development, and set via GitHub Actions +// context during CI. +func actionsActor() (string, error) { + username := os.Getenv(actionsActorKey) + if username == "" { + return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsActorKey) + } + return username, nil +} + +func githubAPIToken() (string, error) { + token := os.Getenv(githubAPITokenKey) + if token == "" { + return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", githubAPITokenKey) + } + return token, nil +} diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go new file mode 100644 index 0000000..5abef39 --- /dev/null +++ b/cmd/readmevalidation/main.go @@ -0,0 +1,180 @@ +// This package is for validating all the README files present in the Registry +// directory. The expectation is that each contributor, module, and template +// will have an associated README containing useful metadata. This metadata must +// be validated for correct structure during CI, because the files themselves +// are parsed and rendered as UI as part of the Registry site build step (the +// Registry site itself lives in a separate repo). +package main + +import ( + "fmt" + "log" + "os" + "sync" + + "coder.com/coder-registry/cmd/github" + "github.com/joho/godotenv" +) + +func main() { + log.Println("Beginning README file validation") + + // Do basic setup + err := godotenv.Load() + if err != nil { + log.Panic(err) + } + actorUsername, err := actionsActor() + if err != nil { + log.Panic(err) + } + ghAPIToken, err := githubAPIToken() + if err != nil { + log.Panic(err) + } + + // Retrieve data necessary from the GitHub API to help determine whether + // certain field changes are allowed + log.Printf("Using GitHub API to determine what fields can be set by user %q\n", actorUsername) + client, err := github.NewClient(github.ClientInit{ + BaseURL: os.Getenv(githubAPIBaseURLKey), + APIToken: ghAPIToken, + }) + if err != nil { + log.Panic(err) + } + tokenUser, err := client.GetUserFromToken() + if err != nil { + log.Panic(err) + } + tokenUserStatus, err := client.GetUserOrgStatus("coder", tokenUser.Login) + if err != nil { + log.Panic(err) + } + var actorOrgStatus github.OrgStatus + if tokenUserStatus == github.OrgStatusMember { + actorOrgStatus, err = client.GetUserOrgStatus("coder", actorUsername) + if err != nil { + log.Panic(err) + } + } else { + log.Println("Provided API token does not belong to a Coder employee. Some README validation steps will be skipped compared to when they run in CI.") + } + fmt.Printf("Script GitHub actor %q has Coder organization status %q\n", actorUsername, actorOrgStatus.String()) + + // Start main validation + log.Println("Starting README validation") + + // Validate file structure of main README directory. Have to do this + // synchronously and before everything else, or else there's no way to for + // the other main validation functions can't make any safe assumptions + // about where they should look in the repo + log.Println("Validating directory structure of the README directory") + err = validateRepoStructure() + if err != nil { + log.Panic(err) + } + + // Set up concurrency for validating each category of README file + var readmeValidationErrors []error + errChan := make(chan error, 1) + doneChan := make(chan struct{}) + wg := sync.WaitGroup{} + go func() { + for err := range errChan { + readmeValidationErrors = append(readmeValidationErrors, err) + } + close(doneChan) + }() + + // Validate contributor README files + wg.Add(1) + go func() { + defer wg.Done() + if err := validateAllContributors(); err != nil { + errChan <- fmt.Errorf("contributor validation: %v", err) + } + }() + + // Validate modules + wg.Add(1) + go func() { + defer wg.Done() + + moveToOuterScopeLater := func() error { + baseRefReadmeFiles, err := aggregateCoderResourceReadmeFiles("modules") + if err != nil { + return err + } + parsed, err := parseCoderResourceFiles("modules", baseRefReadmeFiles, baseRefReadmeFiles, actorOrgStatus) + if err != nil { + return err + } + fmt.Printf("------ got %d back\n", len(parsed)) + + // repo, err := git.PlainClone(dummyGitDirectory, true, &git.CloneOptions{ + // URL: "https://github.com/coder/registry", + // Auth: &http.BasicAuth{}, + // }) + // if err != nil { + // return err + // } + + // head, err := repo.Head() + // if err != nil { + // return err + // } + // activeBranchName := head.Name().Short() + + // tree, err := repo.Worktree() + // if err != nil { + // return err + // } + // err = tree.Checkout(&git.CheckoutOptions{ + // Branch: plumbing.NewBranchReferenceName(activeBranchName), + // Create: false, + // Force: false, + // Keep: true, + // }) + // if err != nil { + // return err + // } + + // files, _ := tree.Filesystem.ReadDir(".") + // for _, f := range files { + // if f.IsDir() { + // fmt.Println(f.Name()) + // } + // } + + return nil + } + + if err := moveToOuterScopeLater(); err != nil { + errChan <- fmt.Errorf("module validation: %v", err) + } + + }() + + // Validate templates + wg.Add(1) + go func() { + defer wg.Done() + }() + + // Clean up and then log errors + wg.Wait() + close(errChan) + <-doneChan + if len(readmeValidationErrors) == 0 { + log.Println("All validation was successful") + return + } + + fmt.Println("---") + log.Println("Encountered the following problems") + for _, err := range readmeValidationErrors { + fmt.Println(err) + } + os.Exit(1) +} diff --git a/cmd/readmevalidation/readmes.go b/cmd/readmevalidation/readmes.go new file mode 100644 index 0000000..78de0e4 --- /dev/null +++ b/cmd/readmevalidation/readmes.go @@ -0,0 +1,155 @@ +package main + +import ( + "bufio" + "errors" + "strings" +) + +const rootRegistryPath = "./registry" + +var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"} + +// Readme represents a single README file within the repo (usually within the +// "/registry" directory). +type readme struct { + filePath string + rawText string +} + +// separateFrontmatter attempts to separate a README file's frontmatter content +// from the main README body, returning both values in that order. It does not +// validate whether the structure of the frontmatter is valid (i.e., that it's +// structured as YAML). +func separateFrontmatter(readmeText string) (string, string, error) { + if readmeText == "" { + return "", "", errors.New("README is empty") + } + + const fence = "---" + fm := "" + body := "" + fenceCount := 0 + lineScanner := bufio.NewScanner( + strings.NewReader(strings.TrimSpace(readmeText)), + ) + for lineScanner.Scan() { + nextLine := lineScanner.Text() + if fenceCount < 2 && nextLine == fence { + fenceCount++ + continue + } + // Break early if the very first line wasn't a fence, because then we + // know for certain that the README has problems + if fenceCount == 0 { + break + } + + // It should be safe to trim each line of the frontmatter on a per-line + // basis, because there shouldn't be any extra meaning attached to the + // indentation. The same does NOT apply to the README; best we can do is + // gather all the lines, and then trim around it + if inReadmeBody := fenceCount >= 2; inReadmeBody { + body += nextLine + "\n" + } else { + fm += strings.TrimSpace(nextLine) + "\n" + } + } + if fenceCount < 2 { + return "", "", errors.New("README does not have two sets of frontmatter fences") + } + if fm == "" { + return "", "", errors.New("readme has frontmatter fences but no frontmatter content") + } + + return fm, strings.TrimSpace(body), nil +} + +// validationPhase represents a specific phase during README validation. It is +// expected that each phase is discrete, and errors during one will prevent a +// future phase from starting. +type validationPhase int + +const ( + // validationPhaseFileStructureValidation indicates when the entire Registry + // directory is being verified for having all files be placed in the file + // system as expected. + validationPhaseFileStructureValidation validationPhase = iota + + // validationPhaseFileLoad indicates when README files are being read from + // the file system + validationPhaseFileLoad + + // validationPhaseReadmeParsing indicates when a README's frontmatter is + // being parsed as YAML. This phase does not include YAML validation. + validationPhaseReadmeParsing + + // validationPhaseReadmeValidation indicates when a README's frontmatter is + // being validated as proper YAML with expected keys. + validationPhaseReadmeValidation + + // validationPhaseAssetCrossReference indicates when a README's frontmatter + // is having all its relative URLs be validated for whether they point to + // valid resources. + validationPhaseAssetCrossReference +) + +func (p validationPhase) String() string { + switch p { + case validationPhaseFileLoad: + return "Filesystem reading" + case validationPhaseReadmeParsing: + return "README parsing" + case validationPhaseReadmeValidation: + return "README validation" + case validationPhaseAssetCrossReference: + return "Cross-referencing asset references" + default: + return "Unknown validation phase" + } +} + +type zippedReadmes struct { + old *readme + new *readme +} + +// zipReadmes takes two slices of README files, and combines them into a map, +// where each key is a file path, and each value is a struct containing the old +// value for the path, and the new value for the path. If the old value exists +// but the new one doesn't, that indicates that a file has been deleted. If the +// new value exists, but the old one doesn't, that indicates that the file was +// created. +func zipReadmes(prevReadmes []readme, newReadmes []readme) map[string]zippedReadmes { + oldMap := map[string]readme{} + for _, rm := range prevReadmes { + oldMap[rm.filePath] = rm + } + + zipped := map[string]zippedReadmes{} + for _, rm := range newReadmes { + old, ok := oldMap[rm.filePath] + if ok { + zipped[rm.filePath] = zippedReadmes{ + old: &old, + new: &rm, + } + } else { + zipped[rm.filePath] = zippedReadmes{ + old: nil, + new: &rm, + } + } + } + for _, old := range oldMap { + _, ok := zipped[old.filePath] + if !ok { + zipped[old.filePath] = zippedReadmes{ + old: &old, + new: nil, + } + } + } + + return zipped +} diff --git a/cmd/readmevalidation/repoStructure.go b/cmd/readmevalidation/repoStructure.go new file mode 100644 index 0000000..951abde --- /dev/null +++ b/cmd/readmevalidation/repoStructure.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path" +) + +func validateCoderResourceSubdirectory(dirPath string) []error { + errs := []error{} + + dir, err := os.Stat(dirPath) + if err != nil { + // It's valid for a specific resource directory not to exist. It's just + // that if it does exist, it must follow specific rules + if !errors.Is(err, os.ErrNotExist) { + errs = append(errs, addFilePathToError(dirPath, err)) + } + return errs + } + + if !dir.IsDir() { + errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath)) + return errs + } + + files, err := os.ReadDir(dirPath) + if err != nil { + errs = append(errs, fmt.Errorf("%q: %v", dirPath, err)) + return errs + } + for _, f := range files { + if !f.IsDir() { + continue + } + + resourceReadmePath := path.Join(dirPath, f.Name(), "README.md") + _, err := os.Stat(resourceReadmePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("%q: README file does not exist", resourceReadmePath)) + } else { + errs = append(errs, addFilePathToError(resourceReadmePath, err)) + } + } + + mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf") + _, err = os.Stat(mainTerraformPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath)) + } else { + errs = append(errs, addFilePathToError(mainTerraformPath, err)) + } + } + + } + + return errs +} + +func validateRegistryDirectory() []error { + dirEntries, err := os.ReadDir(rootRegistryPath) + if err != nil { + return []error{err} + } + + problems := []error{} + for _, e := range dirEntries { + dirPath := path.Join(rootRegistryPath, e.Name()) + if !e.IsDir() { + problems = append(problems, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath)) + continue + } + + readmePath := path.Join(dirPath, "README.md") + _, err := os.Stat(readmePath) + if err != nil { + problems = append(problems, err) + } + + for _, rType := range supportedResourceTypes { + resourcePath := path.Join(dirPath, rType) + if errs := validateCoderResourceSubdirectory(resourcePath); len(errs) != 0 { + problems = append(problems, errs...) + } + } + } + + return problems +} + +func validateRepoStructure() error { + var problems []error + if errs := validateRegistryDirectory(); len(errs) != 0 { + problems = append(problems, errs...) + } + + _, err := os.Stat("./.icons") + if err != nil { + problems = append(problems, err) + } + + // Todo: figure out what other directories we want to make guarantees for + // and add them to this function + if len(problems) != 0 { + return validationPhaseError{ + phase: validationPhaseFileStructureValidation, + errors: problems, + } + } + return nil +} diff --git a/go.mod b/go.mod index e407422..0b88cd4 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,27 @@ module coder.com/coder-registry go 1.23.2 require gopkg.in/yaml.v3 v3.0.1 + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum index a62c313..cbdb91f 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,73 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ= +github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b693440..a87db60 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -22,7 +22,7 @@ module "claude-code" { } ``` -### Prerequisites +## Prerequisites - Node.js and npm must be installed in your workspace to install Claude Code - `screen` must be installed in your workspace to run Claude Code in the background @@ -71,7 +71,7 @@ data "coder_parameter" "ai_prompt" { resource "coder_agent" "main" { # ... env = { - CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter + CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value CODER_MCP_APP_STATUS_SLUG = "claude-code" CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT diff --git a/scripts/contributors/main.go b/scripts/contributors/main.go deleted file mode 100644 index 9091318..0000000 --- a/scripts/contributors/main.go +++ /dev/null @@ -1,39 +0,0 @@ -// This package is for validating all contributors within the main Registry -// directory. It validates that it has nothing but sub-directories, and that -// each sub-directory has a README.md file. Each of those files must then -// describe a specific contributor. The contents of these files will be parsed -// by the Registry site build step, to be displayed in the Registry site's UI. -package main - -import ( - "log" -) - -func main() { - log.Println("Starting README validation") - allReadmeFiles, err := aggregateContributorReadmeFiles() - if err != nil { - log.Panic(err) - } - - log.Printf("Processing %d README files\n", len(allReadmeFiles)) - contributors, err := parseContributorFiles(allReadmeFiles) - log.Printf( - "Processed %d README files as valid contributor profiles", - len(contributors), - ) - if err != nil { - log.Panic(err) - } - - err = validateRelativeUrls(contributors) - if err != nil { - log.Panic(err) - } - log.Println("All relative URLs for READMEs are valid") - - log.Printf( - "Processed all READMEs in the %q directory\n", - rootRegistryPath, - ) -}