Compare commits
24 Commits
main
...
mes/mod-te
| 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 |
19
.env.example
Normal file
19
.env.example
Normal 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=
|
||||||
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
@ -9,6 +9,12 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
validate-readme-files:
|
validate-readme-files:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@ -17,9 +23,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: "1.23.2"
|
go-version: "1.23.2"
|
||||||
- name: Validate contributors
|
- name: Validate contributors
|
||||||
run: go build ./scripts/contributors && ./contributors
|
run: go build ./cmd/readmevalidation && ./readmevalidation
|
||||||
- name: Remove build file artifact
|
- name: Remove build file artifact
|
||||||
run: rm ./contributors
|
run: rm ./readmevalidation
|
||||||
test-terraform:
|
test-terraform:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -135,8 +135,9 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
# Script output
|
# Things needed for CI
|
||||||
/contributors
|
/readmevalidation
|
||||||
|
/readmevalidation-git
|
||||||
|
|
||||||
# Terraform files generated during testing
|
# Terraform files generated during testing
|
||||||
.terraform*
|
.terraform*
|
||||||
|
|||||||
169
cmd/github/github.go
Normal file
169
cmd/github/github.go
Normal 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
cmd/readmevalidation/coderResources.go
Normal file
313
cmd/readmevalidation/coderResources.go
Normal 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@ -13,17 +13,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootRegistryPath = "./registry"
|
var validContributorStatuses = []string{"official", "partner", "community"}
|
||||||
|
|
||||||
var (
|
|
||||||
validContributorStatuses = []string{"official", "partner", "community"}
|
|
||||||
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
|
|
||||||
)
|
|
||||||
|
|
||||||
type readme struct {
|
|
||||||
filePath string
|
|
||||||
rawText string
|
|
||||||
}
|
|
||||||
|
|
||||||
type contributorProfileFrontmatter struct {
|
type contributorProfileFrontmatter struct {
|
||||||
DisplayName string `yaml:"display_name"`
|
DisplayName string `yaml:"display_name"`
|
||||||
@ -44,61 +34,6 @@ type contributorProfile struct {
|
|||||||
filePath string
|
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 {
|
func validateContributorGithubUsername(githubUsername string) error {
|
||||||
if githubUsername == "" {
|
if githubUsername == "" {
|
||||||
return errors.New("missing GitHub username")
|
return errors.New("missing GitHub username")
|
||||||
@ -260,11 +195,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
func addFilePathToError(filePath string, err error) error {
|
func validateContributorProfile(yml contributorProfile) []error {
|
||||||
return fmt.Errorf("%q: %v", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateContributorYaml(yml contributorProfile) []error {
|
|
||||||
allProblems := []error{}
|
allProblems := []error{}
|
||||||
|
|
||||||
if err := validateContributorGithubUsername(yml.frontmatter.GithubUsername); err != nil {
|
if err := validateContributorGithubUsername(yml.frontmatter.GithubUsername); err != nil {
|
||||||
@ -297,7 +228,7 @@ func validateContributorYaml(yml contributorProfile) []error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseContributorProfile(rm readme) (contributorProfile, error) {
|
func parseContributorProfile(rm readme) (contributorProfile, error) {
|
||||||
fm, err := extractFrontmatter(rm.rawText)
|
fm, _, err := separateFrontmatter(rm.rawText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contributorProfile{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
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 {
|
if len(yamlParsingErrors) != 0 {
|
||||||
return nil, validationPhaseError{
|
return nil, validationPhaseError{
|
||||||
phase: "YAML parsing",
|
phase: validationPhaseReadmeParsing,
|
||||||
errors: yamlParsingErrors,
|
errors: yamlParsingErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -339,7 +270,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
|||||||
employeeGithubGroups := map[string][]string{}
|
employeeGithubGroups := map[string][]string{}
|
||||||
yamlValidationErrors := []error{}
|
yamlValidationErrors := []error{}
|
||||||
for _, p := range profilesByUsername {
|
for _, p := range profilesByUsername {
|
||||||
errors := validateContributorYaml(p)
|
errors := validateContributorProfile(p)
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
||||||
continue
|
continue
|
||||||
@ -360,7 +291,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
|||||||
}
|
}
|
||||||
if len(yamlValidationErrors) != 0 {
|
if len(yamlValidationErrors) != 0 {
|
||||||
return nil, validationPhaseError{
|
return nil, validationPhaseError{
|
||||||
phase: "Raw YAML Validation",
|
phase: validationPhaseReadmeValidation,
|
||||||
errors: yamlValidationErrors,
|
errors: yamlValidationErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -379,7 +310,6 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
|||||||
for _, e := range dirEntries {
|
for _, e := range dirEntries {
|
||||||
dirPath := path.Join(rootRegistryPath, e.Name())
|
dirPath := path.Join(rootRegistryPath, e.Name())
|
||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
problems = append(problems, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,7 +327,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
|||||||
|
|
||||||
if len(problems) != 0 {
|
if len(problems) != 0 {
|
||||||
return nil, validationPhaseError{
|
return nil, validationPhaseError{
|
||||||
phase: "FileSystem reading",
|
phase: validationPhaseFileLoad,
|
||||||
errors: problems,
|
errors: problems,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -405,9 +335,7 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
|||||||
return allReadmeFiles, nil
|
return allReadmeFiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRelativeUrls(
|
func validateContributorRelativeUrls(contributors map[string]contributorProfile) error {
|
||||||
contributors map[string]contributorProfile,
|
|
||||||
) error {
|
|
||||||
// This function only validates relative avatar URLs for now, but it can be
|
// This function only validates relative avatar URLs for now, but it can be
|
||||||
// beefed up to validate more in the future
|
// beefed up to validate more in the future
|
||||||
problems := []error{}
|
problems := []error{}
|
||||||
@ -440,7 +368,28 @@ func validateRelativeUrls(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return validationPhaseError{
|
return validationPhaseError{
|
||||||
phase: "Relative URL validation",
|
phase: validationPhaseAssetCrossReference,
|
||||||
errors: problems,
|
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
cmd/readmevalidation/errors.go
Normal file
28
cmd/readmevalidation/errors.go
Normal 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
cmd/readmevalidation/github.go
Normal file
33
cmd/readmevalidation/github.go
Normal 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
cmd/readmevalidation/main.go
Normal file
180
cmd/readmevalidation/main.go
Normal 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
cmd/readmevalidation/readmes.go
Normal file
155
cmd/readmevalidation/readmes.go
Normal 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
cmd/readmevalidation/repoStructure.go
Normal file
114
cmd/readmevalidation/repoStructure.go
Normal 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
go.mod
24
go.mod
@ -3,3 +3,27 @@ module coder.com/coder-registry
|
|||||||
go 1.23.2
|
go 1.23.2
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
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
go.sum
69
go.sum
@ -1,4 +1,73 @@
|
|||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||||
|
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
|
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||||
|
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||||
|
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||||
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
|
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||||
|
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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
|
- 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
|
- `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" {
|
resource "coder_agent" "main" {
|
||||||
# ...
|
# ...
|
||||||
env = {
|
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_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||||
CODER_MCP_APP_STATUS_SLUG = "claude-code"
|
CODER_MCP_APP_STATUS_SLUG = "claude-code"
|
||||||
CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user