Compare commits

...

29 Commits

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