diff --git a/.github/workflows/deploy-registry.yaml b/.github/workflows/deploy-registry.yaml index eb61353aa..eb54665c1 100644 --- a/.github/workflows/deploy-registry.yaml +++ b/.github/workflows/deploy-registry.yaml @@ -9,11 +9,12 @@ on: # Matches release/// # (e.g., "release/whizus/exoscale-zone/v1.0.13") - "release/*/*/v*.*.*" - branches: # Templates get released when merged to main + branches: # Templates and skills get released when merged to main - main paths: - ".github/workflows/deploy-registry.yaml" - "registry/**/templates/**" + - "registry/**/skills/**" - "registry/**/README.md" - ".icons/**" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88feea8dd..f28557bc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ jobs: echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT echo "module=$MODULE" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION" diff --git a/cmd/readmevalidation/coderskills.go b/cmd/readmevalidation/coderskills.go new file mode 100644 index 000000000..a2b75b211 --- /dev/null +++ b/cmd/readmevalidation/coderskills.go @@ -0,0 +1,339 @@ +package main + +import ( + "bufio" + "context" + "errors" + "os" + "path" + "regexp" + "slices" + "strings" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" +) + +// skillsRepoSpecRe matches the "owner/repo" or "owner/repo@ref" format used +// in the skills README sources frontmatter. Owners and repo names allow +// alphanumerics, hyphens, underscores, and dots. Refs allow the same plus +// forward slashes for paths like refs/heads/main. +var skillsRepoSpecRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./-]+)?$`) + +// skillsIconPrefix is the relative path prefix from a skills README to the +// repo-level .icons directory. The skills README lives at depth 3 +// (registry//skills/README.md), so the prefix is three levels up. +// This is distinct from modules and templates, which live at depth 4 and use +// "../../../../.icons/". +const skillsIconPrefix = "../../../.icons/" + +// skillOverride holds per-skill presentation metadata defined in the +// registry README. All fields are optional. +type skillOverride struct { + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + Icon string `yaml:"icon"` + Tags []string `yaml:"tags"` +} + +// skillSource is one entry in the sources list, describing a single source +// repo and optional per-skill overrides. +type skillSource struct { + Repo string `yaml:"repo"` + Skills map[string]skillOverride `yaml:"skills"` +} + +// coderSkillsFrontmatter is the YAML frontmatter schema for +// registry//skills/README.md. +type coderSkillsFrontmatter struct { + Icon string `yaml:"icon"` + Sources []skillSource `yaml:"sources"` +} + +// supportedSkillsTopLevelKeys lists the keys allowed at the root of the +// skills README frontmatter. Nested keys under sources are validated +// separately because the typed unmarshal handles them. +var supportedSkillsTopLevelKeys = []string{"icon", "sources"} + +// coderSkillsReadme represents a parsed skills README file. +type coderSkillsReadme struct { + filePath string + body string + frontmatter coderSkillsFrontmatter +} + +// separateSkillsFrontmatter is like separateFrontmatter but preserves +// indentation in the frontmatter block. The skills README uses nested YAML +// (per-skill metadata under each source), which the indentation-trimming +// behavior of the shared separateFrontmatter helper destroys. +func separateSkillsFrontmatter(readmeText string) (frontmatter string, body string, err error) { + if readmeText == "" { + return "", "", xerrors.New("README is empty") + } + + const fence = "---" + var fmBuilder strings.Builder + var bodyBuilder strings.Builder + fenceCount := 0 + + lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText))) + for lineScanner.Scan() { + nextLine := lineScanner.Text() + if fenceCount < 2 && strings.TrimSpace(nextLine) == fence { + fenceCount++ + continue + } + if fenceCount == 0 { + break + } + if fenceCount >= 2 { + bodyBuilder.WriteString(nextLine) + bodyBuilder.WriteString("\n") + } else { + fmBuilder.WriteString(nextLine) + fmBuilder.WriteString("\n") + } + } + if fenceCount < 2 { + return "", "", xerrors.New("README does not have two sets of frontmatter fences") + } + if strings.TrimSpace(fmBuilder.String()) == "" { + return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content") + } + + return fmBuilder.String(), strings.TrimSpace(bodyBuilder.String()), nil +} + +// isPermittedSkillsIconURL validates that an icon URL references the +// repo-level .icons directory using the 3-deep prefix appropriate for +// skills READMEs, and that the file exists on disk. +func isPermittedSkillsIconURL(checkURL string, readmeFilePath string) error { + if !strings.HasPrefix(checkURL, skillsIconPrefix) { + return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, skillsIconPrefix) + } + + readmeDir := path.Dir(readmeFilePath) + resolvedPath := path.Join(readmeDir, checkURL) + + if _, err := os.Stat(resolvedPath); err != nil { + if os.IsNotExist(err) { + return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL) + } + return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err) + } + + return nil +} + +func validateSkillsIconURL(iconURL string, filePath string) []error { + if iconURL == "" { + return nil + } + + var errs []error + if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") { + errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL)) + return errs + } + + if err := isPermittedSkillsIconURL(iconURL, filePath); err != nil { + errs = append(errs, err) + } + return errs +} + +// validateSkillsTopLevelKeys parses the (indentation-preserved) frontmatter +// as a YAML map and verifies that every top-level key is in the supported +// set. This catches typos like "source:" vs "sources:". +func validateSkillsTopLevelKeys(fm string) []error { + var rawKeys map[string]any + if err := yaml.Unmarshal([]byte(fm), &rawKeys); err != nil { + return []error{xerrors.Errorf("failed to parse frontmatter as YAML map: %v", err)} + } + + var errs []error + for key := range rawKeys { + if !slices.Contains(supportedSkillsTopLevelKeys, key) { + errs = append(errs, xerrors.Errorf("detected unknown top-level key %q (allowed: %s)", key, strings.Join(supportedSkillsTopLevelKeys, ", "))) + } + } + return errs +} + +func validateSkillsSources(sources []skillSource, filePath string) []error { + if len(sources) == 0 { + return []error{xerrors.New("at least one source repo is required under 'sources'")} + } + + var errs []error + for i, src := range sources { + if src.Repo == "" { + errs = append(errs, xerrors.Errorf("sources[%d]: missing required 'repo' field", i)) + continue + } + if !skillsRepoSpecRe.MatchString(src.Repo) { + errs = append(errs, xerrors.Errorf("sources[%d]: repo %q is not a valid owner/repo or owner/repo@ref spec", i, src.Repo)) + } + + for slug, override := range src.Skills { + if !validNameRe.MatchString(slug) { + errs = append(errs, xerrors.Errorf("sources[%d]: skill slug %q contains invalid characters (only alphanumeric and hyphens allowed)", i, slug)) + } + + for _, iconErr := range validateSkillsIconURL(override.Icon, filePath) { + errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, iconErr)) + } + + // validateCoderResourceTags returns an error for nil tags, which is + // fine for modules/templates that require tags but not for skills + // where tags are an optional override. + if override.Tags != nil { + if err := validateCoderResourceTags(override.Tags); err != nil { + errs = append(errs, xerrors.Errorf("sources[%d].skills[%q]: %v", i, slug, err)) + } + } + } + } + return errs +} + +func validateCoderSkillsFrontmatter(filePath string, fm coderSkillsFrontmatter) []error { + var errs []error + + for _, err := range validateSkillsIconURL(fm.Icon, filePath) { + errs = append(errs, addFilePathToError(filePath, err)) + } + + for _, err := range validateSkillsSources(fm.Sources, filePath) { + errs = append(errs, addFilePathToError(filePath, err)) + } + + return errs +} + +func parseCoderSkillsReadme(rm readme) (coderSkillsReadme, []error) { + fm, body, err := separateSkillsFrontmatter(rm.rawText) + if err != nil { + return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)} + } + + keyErrs := validateSkillsTopLevelKeys(fm) + if len(keyErrs) != 0 { + var remapped []error + for _, e := range keyErrs { + remapped = append(remapped, addFilePathToError(rm.filePath, e)) + } + return coderSkillsReadme{}, remapped + } + + yml := coderSkillsFrontmatter{} + if err := yaml.Unmarshal([]byte(fm), &yml); err != nil { + return coderSkillsReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)} + } + + return coderSkillsReadme{ + filePath: rm.filePath, + body: body, + frontmatter: yml, + }, nil +} + +func parseCoderSkillsReadmeFiles(rms []readme) ([]coderSkillsReadme, error) { + var parsed []coderSkillsReadme + var parsingErrs []error + for _, rm := range rms { + p, errs := parseCoderSkillsReadme(rm) + if len(errs) != 0 { + parsingErrs = append(parsingErrs, errs...) + continue + } + parsed = append(parsed, p) + } + if len(parsingErrs) != 0 { + return nil, validationPhaseError{ + phase: validationPhaseReadme, + errors: parsingErrs, + } + } + return parsed, nil +} + +func validateAllCoderSkillsReadmes(readmes []coderSkillsReadme) error { + var validationErrs []error + for _, rm := range readmes { + errs := validateCoderSkillsFrontmatter(rm.filePath, rm.frontmatter) + if len(errs) > 0 { + validationErrs = append(validationErrs, errs...) + } + } + if len(validationErrs) != 0 { + return validationPhaseError{ + phase: validationPhaseReadme, + errors: validationErrs, + } + } + return nil +} + +// aggregateSkillsReadmeFiles walks registry//skills/README.md +// entries, skipping namespaces that do not have a skills directory. +func aggregateSkillsReadmeFiles() ([]readme, error) { + namespaceDirs, err := os.ReadDir(rootRegistryPath) + if err != nil { + return nil, err + } + + var allReadmeFiles []readme + var errs []error + for _, nDir := range namespaceDirs { + if !nDir.IsDir() { + continue + } + + skillsReadmePath := path.Join(rootRegistryPath, nDir.Name(), "skills", "README.md") + rmBytes, err := os.ReadFile(skillsReadmePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + errs = append(errs, err) + continue + } + allReadmeFiles = append(allReadmeFiles, readme{ + filePath: skillsReadmePath, + rawText: string(rmBytes), + }) + } + + if len(errs) != 0 { + return nil, validationPhaseError{ + phase: validationPhaseFile, + errors: errs, + } + } + return allReadmeFiles, nil +} + +func validateAllCoderSkills() error { + allReadmeFiles, err := aggregateSkillsReadmeFiles() + if err != nil { + return err + } + + logger.Info(context.Background(), "processing skills README files", "num_files", len(allReadmeFiles)) + if len(allReadmeFiles) == 0 { + return nil + } + + readmes, err := parseCoderSkillsReadmeFiles(allReadmeFiles) + if err != nil { + return err + } + + if err := validateAllCoderSkillsReadmes(readmes); err != nil { + return err + } + + logger.Info(context.Background(), "processed all skills README files", "num_files", len(readmes)) + return nil +} diff --git a/cmd/readmevalidation/main.go b/cmd/readmevalidation/main.go index 49bfaf832..f3c732242 100644 --- a/cmd/readmevalidation/main.go +++ b/cmd/readmevalidation/main.go @@ -39,6 +39,10 @@ func main() { if err != nil { errs = append(errs, err) } + err = validateAllCoderSkills() + if err != nil { + errs = append(errs, err) + } if len(errs) == 0 { logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath) diff --git a/cmd/readmevalidation/repostructure.go b/cmd/readmevalidation/repostructure.go index c77c723b6..984218869 100644 --- a/cmd/readmevalidation/repostructure.go +++ b/cmd/readmevalidation/repostructure.go @@ -11,7 +11,7 @@ import ( "golang.org/x/xerrors" ) -var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images") +var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images", "skills") // validNameRe validates that names contain only alphanumeric characters and hyphens var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`) diff --git a/registry/coder/skills/README.md b/registry/coder/skills/README.md new file mode 100644 index 000000000..9bcb61882 --- /dev/null +++ b/registry/coder/skills/README.md @@ -0,0 +1,25 @@ +--- +icon: ../../../.icons/coder.svg +sources: + - repo: coder/skills@main + skills: + setup: + display_name: Coder Setup + icon: ../../../.icons/coder.svg + tags: [coder, deployment, configuration] +--- + +# Coder Skills + +Agent skills maintained by [Coder](https://coder.com) for installing, +configuring, and developing with the Coder platform. + +Skills are sourced from [coder/skills](https://github.com/coder/skills) +and served through the registry's API, MCP tools, and +[well-known discovery endpoint](https://agentskills.io/specification). + +## Available Skills + +| Skill | Description | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Coder Setup](https://registry.coder.com/skills/coder/setup) | Install, deploy, or bootstrap a new Coder deployment end-to-end. Covers Docker, Kubernetes/Helm, VM, cloud, HTTPS/domain setup, first admin creation, starter templates, and first workspace. |