Compare commits

...

25 Commits

Author SHA1 Message Date
Michael Smith 02817d9ec1 Merge branch 'main' into mes/mod-temp-valid 2025-04-18 19:02:54 +00:00
Michael Smith a6c1e9c5ea Merge pull request #8 from coder/mes/script-disable
fix: disable cron site check
2025-04-17 19:18:48 -04:00
Michael Smith d2ebc2b1d9 fix: update icon URLs 2025-04-16 15:22:13 +00:00
Michael Smith e6efd71fca wip: more progress 2025-04-16 04:02:45 +00:00
Michael Smith f10f5a4403 chore: add sample data 2025-04-16 02:44:34 +00:00
Michael Smith a00a9ce589 wip: commit progress 2025-04-16 02:17:15 +00:00
Michael Smith 6eef059e21 wip: commit progress on git manipulation 2025-04-16 01:58:36 +00:00
Michael Smith 135d5c8111 fix: remove potential race condition 2025-04-16 01:44:21 +00:00
Michael Smith f23bbca2e7 refactor: start making main() leaner 2025-04-16 00:03:01 +00:00
Michael Smith 19226af067 wip: commit more progress 2025-04-15 23:56:48 +00:00
Michael Smith e888506063 wip: get concurrency stubs set up 2025-04-15 18:10:32 +00:00
Michael Smith aa0b8710d3 wip: commit progress on validation 2025-04-15 17:29:51 +00:00
Michael Smith 17c9667db6 wip: commit progress for module validation 2025-04-15 16:35:33 +00:00
Michael Smith 18680d0a15 chore: add directory validation in separate file 2025-04-15 16:02:33 +00:00
Michael Smith 94ca584b9e refactor: move where validation phase is defined 2025-04-15 15:12:03 +00:00
Michael Smith 6e5d960871 refactor: split README logic into separate file 2025-04-15 15:06:16 +00:00
Michael Smith 3fa316dc37 wip: get basic GH API call stuff working 2025-04-15 14:54:30 +00:00
Michael Smith 9f035798d1 wip: commit progress 2025-04-15 14:32:38 +00:00
Michael Smith 25d301c654 wip: commit progress 2025-04-14 20:40:25 +00:00
Michael Smith d2c5f8d3bd fix: actually add the test calls 2025-04-14 18:27:10 +00:00
Michael Smith 0a597c23f4 test: try logging refs 2025-04-14 18:25:24 +00:00
Michael Smith ec1b4a72cb test: see how CI output works 2025-04-14 18:13:22 +00:00
Michael Smith 860a633e11 wip: add support for reading env from CI 2025-04-14 18:09:43 +00:00
Michael Smith a2abeaee2f fix: update script references for CI 2025-04-14 17:58:59 +00:00
Michael Smith 73f3ea23c0 refactor: move to cmd dir 2025-04-14 17:53:54 +00:00
15 changed files with 1148 additions and 127 deletions
+19
View File
@@ -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=
+8 -2
View File
@@ -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:
+3 -2
View File
@@ -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*
+169
View File
@@ -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
}
}
+313
View File
@@ -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
}
@@ -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
}
+28
View File
@@ -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)
}
+33
View File
@@ -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
}
+180
View File
@@ -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)
}
+155
View File
@@ -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
}
+114
View File
@@ -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
}
+24
View File
@@ -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
)
+69
View File
@@ -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=
+2 -2
View File
@@ -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
-39
View File
@@ -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,
)
}