mirror of
https://github.com/coder/registry.git
synced 2026-06-02 20:48:14 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02817d9ec1 | |||
| d2ebc2b1d9 | |||
| e6efd71fca | |||
| f10f5a4403 | |||
| a00a9ce589 | |||
| 6eef059e21 | |||
| 135d5c8111 | |||
| f23bbca2e7 | |||
| 19226af067 | |||
| e888506063 | |||
| aa0b8710d3 | |||
| 17c9667db6 | |||
| 18680d0a15 | |||
| 94ca584b9e | |||
| 6e5d960871 | |||
| 3fa316dc37 | |||
| 9f035798d1 | |||
| 25d301c654 | |||
| d2c5f8d3bd | |||
| 0a597c23f4 | |||
| ec1b4a72cb | |||
| 860a633e11 | |||
| a2abeaee2f | |||
| 73f3ea23c0 |
@@ -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=
|
||||
@@ -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
@@ -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*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user