ci: frontmatter linter, PR/issue templates, and release workflow (#30)

Adds GitHub infrastructure across three areas:

Frontmatter linter (scripts/lint-frontmatter.py + .github/workflows/lint.yml)
- Runs on every push and PR
- Skills: name + description required, name must match directory, kebab-case enforced
- Commands: description + argument-hint required, argument-hint must be bracketed
- Inline GitHub annotations with file + line number on failure

PR template + issue templates (.github/pull_request_template.md, .github/ISSUE_TEMPLATE/)
- PR template requires a linked issue for new skills/commands/structural changes
- Three issue templates: bug, new-skill-or-command, structural-change

Release workflow (scripts/extract-release-notes.py + .github/workflows/release.yml)
- Triggers on any v* tag push
- Extracts matching CHANGELOG section and publishes a GitHub Release
- CHANGELOG updated with v1.0.1 entry (argument-hint quoting fix, PR #28)
This commit is contained in:
MC Dean
2026-08-08 14:28:08 +00:00
committed by GitHub
parent 440e222184
commit f0ae24d2aa
9 changed files with 306 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
---
name: Bug report
about: A skill or command behaves incorrectly or is missing expected content
labels: bug
---
## What's broken
<!-- Which skill or command, and what goes wrong? -->
## Steps to reproduce
1.
2.
## Expected behaviour
## Actual behaviour
<!-- A diff or screenshot helps if you have one. -->
@@ -0,0 +1,28 @@
---
name: New skill or command proposal
about: Propose a new skill (noun — domain knowledge) or command (verb — workflow)
labels: proposal
---
## What are you proposing?
- [ ] New skill
- [ ] New command
- [ ] Something else (describe below)
## Which plugin does it belong in?
<!-- design-ops / design-research / design-systems / designer-toolkit /
interaction-design / prototyping-testing / ui-design / ux-strategy / visual-critique -->
## Name and description
<!-- Skills are nouns (e.g. `color-token`). Commands are verbs (e.g. `audit-contrast`).
Paste a draft `name:` and `description:` line so we can discuss the scope. -->
**name:**
**description:**
## Why does this belong in this collection?
<!-- What design task does it unlock? What's missing without it? -->
@@ -0,0 +1,19 @@
---
name: Structural change
about: New plugin, directory reorganisation, schema change, or other larger change
labels: discussion
---
## What do you want to change?
## Why?
<!-- What problem does the current structure cause? -->
## Proposed approach
<!-- Rough sketch is fine — we'll refine it together before any PR lands. -->
## What would break or need updating?
<!-- Other files, the build script, the marketplace manifest, docs, etc. -->
+22
View File
@@ -0,0 +1,22 @@
## Linked issue
<!-- Bug fixes and typos can skip this line.
New skills, commands, or structural changes require an open issue first —
link it here or this PR will be closed without review (see CONTRIBUTING.md). -->
Closes #
## Change type
- [ ] Bug fix or typo (no issue required)
- [ ] New skill
- [ ] New command
- [ ] Structural / other (new plugin, directory change, etc.)
## Checklist
- [ ] Every `SKILL.md` has `name` and `description` in its frontmatter
- [ ] Each skill's `name` value matches its directory name exactly
- [ ] Every command file has `description` and `argument-hint` in its frontmatter
- [ ] No command references skills from another plugin
- [ ] This PR contains one focused change
+16
View File
@@ -0,0 +1,16 @@
name: Lint frontmatter
on:
push:
branches: ["**"]
pull_request:
jobs:
lint:
name: Skill & command frontmatter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check SKILL.md and command frontmatter
run: python3 scripts/lint-frontmatter.py
+27
View File
@@ -0,0 +1,27 @@
name: Release
on:
push:
tags:
- 'v[0-9]*'
permissions:
contents: write
jobs:
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract changelog section
run: python3 scripts/extract-release-notes.py "${{ github.ref_name }}" > release-notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--notes-file release-notes.md
+8
View File
@@ -4,6 +4,14 @@ All notable changes to this collection are documented here.
---
## [1.0.1] — 2026-07-12
### Fixed
- Quote `argument-hint` values in command frontmatter so Copilot CLI ≥ 1.0.65 parses them correctly and loads all skills ([#28](https://github.com/Owl-Listener/designer-skills/pull/28))
---
## [1.0.0] — 2026-06-11
First stable release. Tagging the current state of main as v1.0.0 to give integrators a stable version to pin to.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Extract the body of a CHANGELOG section for a given version tag.
Usage: python3 scripts/extract-release-notes.py v1.0.1
Prints the section body to stdout; exits 1 if the version isn't found.
"""
import re
import sys
from pathlib import Path
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <version>", file=sys.stderr)
sys.exit(1)
version = sys.argv[1].lstrip("v") # "1.0.1" from "v1.0.1"
text = (Path(__file__).resolve().parent.parent / "CHANGELOG.md").read_text()
# Each section runs from its ## [x.y.z] heading to the next --- separator.
pattern = rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^---|\Z)"
m = re.search(pattern, text, re.MULTILINE | re.DOTALL)
if not m:
print(f"error: no CHANGELOG section found for version {version}", file=sys.stderr)
sys.exit(1)
print(m.group(1).strip())
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Lint SKILL.md and command frontmatter per CONTRIBUTING.md rules.
Checks applied to every */skills/*/SKILL.md:
- frontmatter block present (opening and closing ---)
- `name` field present and non-empty
- `description` field present and non-empty
- `name` value matches the skill's directory name
- `name` is kebab-case (lowercase letters, digits, hyphens)
Checks applied to every */commands/*.md:
- frontmatter block present
- `description` field present and non-empty
- `argument-hint` field present and non-empty
- `argument-hint` is a bracketed placeholder, e.g. "[what to pass]"
"""
import os
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
_errors: list[tuple[str, int | None, str]] = []
IN_CI = "GITHUB_ACTIONS" in os.environ
def _report(path: Path, msg: str, line: int | None = None) -> None:
rel = str(path.relative_to(ROOT))
_errors.append((rel, line, msg))
if IN_CI:
loc = f"file={rel}" + (f",line={line}" if line is not None else "")
print(f"::error {loc}::{msg}", flush=True)
else:
loc = f"{rel}:{line}" if line is not None else rel
print(f"ERROR {loc}: {msg}", flush=True)
def _parse_frontmatter(path: Path) -> dict[str, str] | None:
"""Parse the leading YAML frontmatter block; return field dict or None."""
text = path.read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
if not m:
return None
fields: dict[str, str] = {}
for raw_line in m.group(1).splitlines():
if ":" in raw_line:
key, _, value = raw_line.partition(":")
fields[key.strip()] = value.strip()
return fields
def _line_of_key(path: Path, key: str) -> int | None:
"""Return the 1-based line number of `key:` inside the frontmatter, or None."""
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if re.match(rf"^{re.escape(key)}\s*:", line):
return i
return None
def lint_skills() -> None:
for skill_md in sorted(ROOT.glob("*/skills/*/SKILL.md")):
skill_dir = skill_md.parent.name
fm = _parse_frontmatter(skill_md)
if fm is None:
_report(skill_md, "no frontmatter block found (file must start with ---)", line=1)
continue
name = fm.get("name", "")
desc = fm.get("description", "")
if not name:
_report(skill_md, "required field `name` is missing or empty",
line=_line_of_key(skill_md, "name") or 2)
if not desc:
_report(skill_md, "required field `description` is missing or empty",
line=_line_of_key(skill_md, "description") or 3)
if name and name != skill_dir:
_report(skill_md,
f"`name: {name}` must match the skill's directory name `{skill_dir}`",
line=_line_of_key(skill_md, "name"))
if name and not re.fullmatch(r"[a-z][a-z0-9-]*", name):
_report(skill_md,
f"`name: {name}` must be kebab-case "
"(lowercase letters, digits, and hyphens only)",
line=_line_of_key(skill_md, "name"))
def lint_commands() -> None:
for cmd_md in sorted(ROOT.glob("*/commands/*.md")):
fm = _parse_frontmatter(cmd_md)
if fm is None:
_report(cmd_md, "no frontmatter block found (file must start with ---)", line=1)
continue
desc = fm.get("description", "")
arg_hint = fm.get("argument-hint", "")
if not desc:
_report(cmd_md, "required field `description` is missing or empty",
line=_line_of_key(cmd_md, "description") or 2)
if not arg_hint:
_report(cmd_md, "required field `argument-hint` is missing or empty",
line=_line_of_key(cmd_md, "argument-hint") or 3)
if arg_hint and not re.match(r'"?\[.+\]"?$', arg_hint):
_report(cmd_md,
f'`argument-hint` must be a bracketed placeholder '
f'like "[what to pass]" — got: {arg_hint!r}',
line=_line_of_key(cmd_md, "argument-hint"))
def main() -> None:
lint_skills()
lint_commands()
skills_count = len(list(ROOT.glob("*/skills/*/SKILL.md")))
commands_count = len(list(ROOT.glob("*/commands/*.md")))
if _errors:
print(
f"\nFrontmatter lint failed — {len(_errors)} error(s) "
f"across {skills_count} skills and {commands_count} commands.",
flush=True,
)
sys.exit(1)
print(
f"OK — {skills_count} skills and {commands_count} commands "
"passed all frontmatter checks.",
flush=True,
)
if __name__ == "__main__":
main()