mirror of
https://github.com/Owl-Listener/designer-skills.git
synced 2026-09-19 03:07:41 +08:00
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:
@@ -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. -->
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user