feat: add skills as namespace-level catalogue entries with external source repos (#884)

## Summary

Adds skills as a catalogue resource type in the registry. Each namespace
declares its skill source repos and per-skill presentation metadata in
`registry/<namespace>/skills/README.md`. The registry-server build
pipeline clones source repos, auto-discovers skills, and serves them
with the metadata defined here.

## Catalogue format

The skills README uses structured YAML frontmatter with nested per-skill
metadata:

```yaml
---
icon: ../../../.icons/coder.svg
sources:
  - repo: coder/skills@main
    skills:
      setup:
        display_name: Setup & Configuration
        icon: ../../../.icons/coder.svg
        tags: [coder, deployment, configuration]
---
```

- `icon` (top-level): default icon for skills without a per-skill
override
- `sources[].repo`: GitHub repo to clone (`owner/repo@ref`)
- `sources[].skills`: per-skill overrides for `display_name`,
`description`, `icon`, and `tags`
- Multiple repos per namespace are supported
- Skills not listed in the `skills` map are still discovered with
default metadata
- `name` and `description` always come from the source repo's SKILL.md
unless overridden

## Changes

- `registry/coder/skills/README.md`: Coder namespace pointing to
`coder/skills@main` with per-skill metadata
- `registry/DevelopmentCats/skills/README.md`: Test namespace pointing
to `DevelopmentCats/skills@main` (remove before merge)
- `registry/DevelopmentCats/README.md` + `.images/avatar.svg`: Test
namespace profile (remove before merge)
- `.github/workflows/deploy-registry.yaml`: Added
`registry/**/skills/**` path trigger
- `.github/workflows/release.yml`: Skill/module path detection in tag
extraction
- `.github/workflows/version-bump.yaml`: Added `registry/**/skills/**`
path trigger
- `cmd/readmevalidation/repostructure.go`: Added `skills` to supported
namespace directories

## Related

-
[registry-server#442](https://github.com/coder/registry-server/pull/442):
Build pipeline, API, MCP, frontend, and well-known discovery for skills
- [coder/skills](https://github.com/coder/skills): Coder's official
skills source repo
- [Problem
Document](https://www.notion.so/35dd579be59281a4b657d02174667e4f):
Skills as First-Class Registry Catalogue Items

> 🤖 This PR was updated with the help of Coder Agents.
This commit is contained in:
DevCats
2026-05-22 12:20:55 -05:00
committed by GitHub
parent 1601ab3e8b
commit 20051c7089
6 changed files with 372 additions and 2 deletions
+2 -1
View File
@@ -9,11 +9,12 @@ on:
# Matches release/<namespace>/<resource_name>/<semantic_version>
# (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/**"
+1
View File
@@ -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"
+339
View File
@@ -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/<namespace>/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/<namespace>/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/<namespace>/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
}
+4
View File
@@ -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)
+1 -1
View File
@@ -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])?$`)
+25
View File
@@ -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. |