From 0e86c764f7e80ce66c35f6ce6d41ddbb6d57dcf5 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Tue, 28 Apr 2026 18:06:01 -0500
Subject: [PATCH 01/13] feat: automate community catalog submissions with
validation and PR generation
Add GitHub Actions workflows and scripts to automate extension and preset
catalog submissions. Validation is metadata-only (no archive extraction).
- catalog-validate.yml: auto-validates submission issues
- catalog-pr.yml: generates PR to update catalog.community.json
- catalog-validate.py: issue parsing, field validation, URL reachability
- catalog-pr.py: catalog entry generation and PR creation
- catalog-generate-table.py: formatted catalog table generation
- Updated publishing/development guides
- New presets/DEVELOPING.md
Closes #2400
---
.github/CODEOWNERS | 5 +
.github/scripts/catalog-generate-table.py | 194 ++++++
.github/scripts/catalog-pr.py | 138 ++++
.github/scripts/catalog-validate.py | 806 ++++++++++++++++++++++
.github/workflows/catalog-pr.yml | 244 +++++++
.github/workflows/catalog-validate.yml | 211 ++++++
extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 21 +-
extensions/EXTENSION-PUBLISHING-GUIDE.md | 554 ++-------------
extensions/EXTENSION-USER-GUIDE.md | 2 +-
extensions/README.md | 7 +-
integrations/CONTRIBUTING.md | 2 +
presets/DEVELOPING.md | 180 +++++
presets/PUBLISHING.md | 310 ++-------
13 files changed, 1883 insertions(+), 791 deletions(-)
create mode 100644 .github/scripts/catalog-generate-table.py
create mode 100644 .github/scripts/catalog-pr.py
create mode 100644 .github/scripts/catalog-validate.py
create mode 100644 .github/workflows/catalog-pr.yml
create mode 100644 .github/workflows/catalog-validate.yml
create mode 100644 presets/DEVELOPING.md
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index a60b7a0306..91faf3a86c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,3 +1,8 @@
# Global code owner
* @mnriem
+# Community catalog files — require maintainer approval even for bot PRs
+extensions/catalog.community.json @mnriem
+integrations/catalog.community.json @mnriem
+presets/catalog.community.json @mnriem
+
diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py
new file mode 100644
index 0000000000..eafb5c361e
--- /dev/null
+++ b/.github/scripts/catalog-generate-table.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""Generate a markdown table from a community catalog JSON file.
+
+Reads a catalog.community.json and replaces content between marker comments
+in a target markdown file. If the markers are not present the table is
+printed to stdout.
+
+Markers expected in the markdown file:
+
+ ... (old table content replaced) ...
+
+
+Usage:
+ python .github/scripts/catalog-generate-table.py \
+ --catalog presets/catalog.community.json \
+ --type preset \
+ --target docs/community/presets.md
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+START_MARKER = ""
+END_MARKER = ""
+
+
+# ---------------------------------------------------------------------------
+# Table builders — one per catalog type
+# ---------------------------------------------------------------------------
+
+def _repo_display_name(url: str) -> str:
+ """Extract the repository name from a GitHub URL."""
+ # https://github.com/user/spec-kit-foo → spec-kit-foo
+ return url.rstrip("/").rsplit("/", 1)[-1]
+
+
+def _provides_str_preset(provides: dict) -> str:
+ parts: list[str] = []
+ t = provides.get("templates", 0)
+ c = provides.get("commands", 0)
+ s = provides.get("scripts", 0)
+ if t:
+ parts.append(f"{t} template{'s' if t != 1 else ''}")
+ if c:
+ parts.append(f"{c} command{'s' if c != 1 else ''}")
+ if s:
+ parts.append(f"{s} script{'s' if s != 1 else ''}")
+ return ", ".join(parts) or "—"
+
+
+def _requires_str_preset(requires: dict) -> str:
+ exts = requires.get("extensions", [])
+ if exts:
+ return ", ".join(f"{e} extension" for e in exts)
+ return "—"
+
+
+def build_preset_table(catalog: dict) -> str:
+ """Build a markdown table for presets."""
+ entries = catalog.get("presets", {})
+ lines: list[str] = []
+ lines.append("| Preset | Purpose | Provides | Requires | URL |")
+ lines.append("|--------|---------|----------|----------|-----|")
+
+ for _id in sorted(entries):
+ e = entries[_id]
+ name = e.get("name", _id)
+ desc = e.get("description", "")
+ provides = _provides_str_preset(e.get("provides", {}))
+ requires = _requires_str_preset(e.get("requires", {}))
+ repo_url = e.get("repository", "")
+ repo_name = _repo_display_name(repo_url)
+ lines.append(
+ f"| {name} | {desc} | {provides} | {requires} "
+ f"| [{repo_name}]({repo_url}) |"
+ )
+
+ return "\n".join(lines)
+
+
+def _provides_str_extension(provides: dict) -> str:
+ parts: list[str] = []
+ c = provides.get("commands", 0)
+ h = provides.get("hooks", 0)
+ if c:
+ parts.append(f"{c} command{'s' if c != 1 else ''}")
+ if h:
+ parts.append(f"{h} hook{'s' if h != 1 else ''}")
+ return ", ".join(parts) or "—"
+
+
+def build_extension_table(catalog: dict) -> str:
+ """Build a markdown table for extensions."""
+ entries = catalog.get("extensions", {})
+ lines: list[str] = []
+ lines.append("| Extension | Purpose | Provides | URL |")
+ lines.append("|-----------|---------|----------|-----|")
+
+ for _id in sorted(entries):
+ e = entries[_id]
+ name = e.get("name", _id)
+ desc = e.get("description", "")
+ provides = _provides_str_extension(e.get("provides", {}))
+ repo_url = e.get("repository", "")
+ repo_name = _repo_display_name(repo_url)
+ lines.append(
+ f"| {name} | {desc} | {provides} "
+ f"| [{repo_name}]({repo_url}) |"
+ )
+
+ return "\n".join(lines)
+
+
+BUILDERS = {
+ "preset": build_preset_table,
+ "extension": build_extension_table,
+}
+
+
+# ---------------------------------------------------------------------------
+# File updater
+# ---------------------------------------------------------------------------
+
+def update_file(path: Path, table: str) -> bool:
+ """Replace content between markers in *path*. Returns True if updated."""
+ content = path.read_text()
+
+ pattern = re.compile(
+ rf"({re.escape(START_MARKER)})\n.*?\n({re.escape(END_MARKER)})",
+ re.DOTALL,
+ )
+
+ if not pattern.search(content):
+ return False
+
+ new_content = pattern.sub(rf"\1\n{table}\n\2", content)
+
+ if new_content != content:
+ path.write_text(new_content)
+ return True
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--catalog", required=True,
+ help="Path to catalog.community.json",
+ )
+ parser.add_argument(
+ "--type", required=True, choices=list(BUILDERS),
+ help="Catalog type",
+ )
+ parser.add_argument(
+ "--target",
+ help="Markdown file to update (must contain marker comments)",
+ )
+ args = parser.parse_args()
+
+ with open(args.catalog) as f:
+ catalog = json.load(f)
+
+ builder = BUILDERS[args.type]
+ table = builder(catalog)
+
+ if args.target:
+ target = Path(args.target)
+ if not target.exists():
+ print(f"Error: target file not found: {target}", file=sys.stderr)
+ sys.exit(1)
+ if update_file(target, table):
+ print(f"Updated {target}")
+ else:
+ print(
+ f"Warning: markers {START_MARKER} / {END_MARKER} not found "
+ f"in {target}. Printing table to stdout.",
+ file=sys.stderr,
+ )
+ print(table)
+ else:
+ print(table)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/catalog-pr.py b/.github/scripts/catalog-pr.py
new file mode 100644
index 0000000000..aa1ac46c01
--- /dev/null
+++ b/.github/scripts/catalog-pr.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Update a community catalog JSON file with a validated entry.
+
+Reads the validation result and entry produced by catalog-validate.py,
+inserts or replaces the entry in the catalog, sorts entries alphabetically,
+and optionally regenerates a docs table.
+
+Usage (typically called from GitHub Actions):
+ python .github/scripts/catalog-pr.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+
+ python .github/scripts/catalog-pr.py \
+ --catalog presets/catalog.community.json \
+ --type preset \
+ --table-target docs/community/presets.md
+
+Environment variables:
+ GITHUB_OUTPUT — Path to GitHub Actions output file (optional)
+
+Inputs (files produced by catalog-validate.py):
+ /tmp/validation-result.json — metadata including item_id, valid, is_update
+ /tmp/catalog-entry.json — the entry to insert/replace
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+CATALOG_KEY = {
+ "extension": "extensions",
+ "preset": "presets",
+}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--catalog", required=True,
+ help="Path to the catalog JSON file",
+ )
+ parser.add_argument(
+ "--type", required=True, choices=list(CATALOG_KEY),
+ help="Catalog type",
+ )
+ parser.add_argument(
+ "--result", default="/tmp/validation-result.json",
+ help="Path to validation result JSON",
+ )
+ parser.add_argument(
+ "--entry", default="/tmp/catalog-entry.json",
+ help="Path to catalog entry JSON",
+ )
+ parser.add_argument(
+ "--table-target",
+ help="Markdown file to regenerate table in (must contain marker comments)",
+ )
+ args = parser.parse_args()
+
+ # Load validation result
+ result_path = Path(args.result)
+ if not result_path.exists():
+ print(f"Error: result file not found: {result_path}", file=sys.stderr)
+ sys.exit(2)
+ result = json.loads(result_path.read_text())
+
+ if not result.get("valid"):
+ print("Submission is not valid — skipping catalog update.")
+ _set_output("skipped", "true")
+ sys.exit(0)
+
+ # Load entry
+ entry_path = Path(args.entry)
+ if not entry_path.exists():
+ print(f"Error: entry file not found: {entry_path}", file=sys.stderr)
+ sys.exit(2)
+ new_entry = json.loads(entry_path.read_text())
+
+ item_id = result["item_id"]
+ is_update = result.get("is_update", False)
+ cat_key = CATALOG_KEY[args.type]
+
+ # Update catalog
+ catalog_path = Path(args.catalog)
+ with open(catalog_path) as f:
+ catalog = json.load(f)
+
+ catalog[cat_key][item_id] = new_entry
+ catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+ catalog[cat_key] = dict(sorted(catalog[cat_key].items()))
+
+ with open(catalog_path, "w") as f:
+ json.dump(catalog, f, indent=2)
+ f.write("\n")
+
+ print(f"Updated {catalog_path}: {'replaced' if is_update else 'added'} {item_id}")
+
+ # Regenerate docs table if requested
+ if args.table_target:
+ table_script = Path(__file__).parent / "catalog-generate-table.py"
+ subprocess.run(
+ [
+ sys.executable, str(table_script),
+ "--catalog", args.catalog,
+ "--type", args.type,
+ "--target", args.table_target,
+ ],
+ check=True,
+ )
+
+ # Set outputs for the workflow
+ action = "update" if is_update else "add"
+ _set_output("skipped", "false")
+ _set_output("item_id", item_id)
+ _set_output("is_update", str(is_update).lower())
+ _set_output("action", action.title())
+ _set_output("action_verb", f"{action.title()}s")
+ _set_output("branch", f"community/{action}-{args.type}-{item_id}")
+
+
+def _set_output(name: str, value: str) -> None:
+ """Write a GitHub Actions output variable."""
+ gh_output = os.environ.get("GITHUB_OUTPUT")
+ if gh_output:
+ with open(gh_output, "a") as f:
+ f.write(f"{name}={value}\n")
+ # Also print for local debugging
+ print(f" output: {name}={value}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
new file mode 100644
index 0000000000..03435052ac
--- /dev/null
+++ b/.github/scripts/catalog-validate.py
@@ -0,0 +1,806 @@
+#!/usr/bin/env python3
+"""Validate a community catalog submission from a GitHub issue form.
+
+Parses the structured markdown body produced by GitHub issue forms,
+validates all fields, and optionally generates the catalog JSON entry.
+
+Usage (typically called from GitHub Actions):
+ python .github/scripts/catalog-validate.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+
+Environment variables:
+ ISSUE_BODY — The issue body markdown (required)
+ ISSUE_NUMBER — The issue number (optional, for reporting)
+ GITHUB_TOKEN — Token for authenticated URL checks (optional)
+ GITHUB_OUTPUT — Path to GitHub Actions output file (optional)
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# Issue body parser
+# ---------------------------------------------------------------------------
+
+def parse_issue_body(body: str) -> dict[str, str]:
+ """Parse a GitHub issue form body into {label: value} pairs.
+
+ GitHub issue forms render as markdown with ``### Label`` headers
+ followed by the user's input. Checkbox groups render as lists of
+ ``- [X]`` / ``- [ ]`` items.
+ """
+ fields: dict[str, str] = {}
+ current_label: str | None = None
+ current_lines: list[str] = []
+
+ for line in body.splitlines():
+ if line.startswith("### "):
+ # Store previous field
+ if current_label is not None:
+ fields[current_label] = "\n".join(current_lines).strip()
+ current_label = line[4:].strip()
+ current_lines = []
+ else:
+ current_lines.append(line)
+
+ # Don't forget the last field
+ if current_label is not None:
+ fields[current_label] = "\n".join(current_lines).strip()
+
+ return fields
+
+
+# Mapping from issue form labels to internal field keys — one per catalog type
+EXTENSION_LABEL_TO_KEY: dict[str, str] = {
+ "Extension ID": "item_id",
+ "Extension Name": "item_name",
+ "Version": "version",
+ "Description": "description",
+ "Author": "author",
+ "Repository URL": "repository",
+ "Download URL": "download_url",
+ "License": "license",
+ "Homepage (optional)": "homepage",
+ "Documentation URL (optional)": "documentation",
+ "Changelog URL (optional)": "changelog",
+ "Required Spec Kit Version": "speckit_version",
+ "Required Tools (optional)": "required_tools",
+ "Number of Commands": "commands_count",
+ "Number of Hooks (optional)": "hooks_count",
+ "Tags": "tags",
+ "Key Features": "features",
+ "Testing Checklist": "testing_checklist",
+ "Submission Requirements": "requirements_checklist",
+ "Testing Details": "testing_details",
+ "Example Usage": "example_usage",
+ "Proposed Catalog Entry": "catalog_entry",
+ "Additional Context": "additional_context",
+}
+
+PRESET_LABEL_TO_KEY: dict[str, str] = {
+ "Preset ID": "item_id",
+ "Preset Name": "item_name",
+ "Version": "version",
+ "Description": "description",
+ "Author": "author",
+ "Repository URL": "repository",
+ "Download URL": "download_url",
+ "License": "license",
+ "Required Spec Kit Version": "speckit_version",
+ "Templates Provided": "templates_provided",
+ "Commands Provided (optional)": "commands_provided",
+ "Tags": "tags",
+ "Key Features": "features",
+ "Testing Checklist": "testing_checklist",
+ "Submission Requirements": "requirements_checklist",
+ "Additional Context": "additional_context",
+}
+
+LABEL_MAPS: dict[str, dict[str, str]] = {
+ "extension": EXTENSION_LABEL_TO_KEY,
+ "preset": PRESET_LABEL_TO_KEY,
+}
+
+# Catalog JSON top-level key per type
+CATALOG_KEY: dict[str, str] = {
+ "extension": "extensions",
+ "preset": "presets",
+}
+
+
+def normalize_fields(raw: dict[str, str], catalog_type: str) -> dict[str, str]:
+ """Map label-keyed fields to internal keys."""
+ label_map = LABEL_MAPS[catalog_type]
+ result: dict[str, str] = {}
+ for label, value in raw.items():
+ key = label_map.get(label)
+ if key:
+ result[key] = value
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Validators
+# ---------------------------------------------------------------------------
+
+# Each validator returns (ok, message). *ok* is True on pass.
+
+_ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
+_SEMVER_RE = re.compile(
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
+ r"(-((0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
+ r"(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
+ r"(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$"
+)
+_VERSION_CONSTRAINT_RE = re.compile(r"^[><=!~]+\d")
+_URL_RE = re.compile(r"^https?://\S+$")
+
+
+def _present(value: str | None) -> bool:
+ return bool(value and value.strip() and value.strip() != "_No response_")
+
+
+def _parse_semver(version: str) -> tuple[int, ...]:
+ """Parse a semver string into a comparable tuple of ints."""
+ # Strip pre-release/build metadata for comparison
+ base = version.split("-")[0].split("+")[0]
+ return tuple(int(p) for p in base.split("."))
+
+
+def validate_item_id(
+ value: str, catalog: dict, catalog_type: str,
+) -> tuple[bool, str, bool]:
+ """Validate item ID. Returns (ok, message, is_update)."""
+ label = catalog_type.title() # "Extension" or "Preset"
+ cat_key = CATALOG_KEY[catalog_type]
+ if not _present(value):
+ return False, f"{label} ID is required.", False
+ value = value.strip()
+ if not _ID_RE.match(value):
+ return False, (
+ f"{label} ID `{value}` is invalid. "
+ f"Use only lowercase letters, digits, and hyphens (e.g., `my-{catalog_type}`)."
+ ), False
+ if value in catalog.get(cat_key, {}):
+ existing = catalog[cat_key][value]
+ return True, (
+ f"{label} ID `{value}` already exists (v{existing.get('version', '?')}). "
+ "This will be processed as an **update**."
+ ), True
+ return True, f"{label} ID `{value}` is valid and available (new submission).", False
+
+
+def validate_version(
+ value: str, *, is_update: bool = False, catalog: dict | None = None,
+ item_id: str = "", catalog_type: str = "extension",
+) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "Version is required."
+ value = value.strip()
+ if not _SEMVER_RE.match(value):
+ return False, (
+ f"Version `{value}` is not valid semver. "
+ "Use the format `X.Y.Z` (e.g., `1.0.0`)."
+ )
+ if is_update and catalog and item_id:
+ cat_key = CATALOG_KEY[catalog_type]
+ existing = catalog.get(cat_key, {}).get(item_id, {})
+ old_version = existing.get("version", "0.0.0")
+ try:
+ if _parse_semver(value) <= _parse_semver(old_version):
+ return False, (
+ f"Version `{value}` must be higher than the existing "
+ f"version `{old_version}`."
+ )
+ except (ValueError, TypeError):
+ pass # If existing version is unparseable, skip comparison
+ return True, f"Version `{value}` is valid (upgrade from `{old_version}`)."
+ return True, f"Version `{value}` is valid."
+
+
+def validate_description(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "Description is required."
+ value = value.strip()
+ if len(value) > 200:
+ return False, (
+ f"Description is {len(value)} characters — please keep it under 200."
+ )
+ return True, "Description is valid."
+
+
+def validate_url(
+ value: str, field_name: str, *, required: bool = True,
+) -> tuple[bool, str]:
+ if not _present(value):
+ if required:
+ return False, f"{field_name} is required."
+ return True, f"{field_name} not provided (optional)."
+ value = value.strip()
+ if not _URL_RE.match(value):
+ return False, f"{field_name} `{value}` is not a valid URL."
+ return True, f"{field_name} URL format is valid."
+
+
+def check_url_reachable(
+ url: str, field_name: str, token: str | None = None,
+) -> tuple[bool, str]:
+ """HTTP HEAD check. Returns (ok, message)."""
+ if not _present(url):
+ return True, "" # skip if empty/optional
+ url = url.strip()
+ req = urllib.request.Request(url, method="HEAD")
+ req.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
+ if token and "github.com" in url:
+ req.add_header("Authorization", f"token {token}")
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ if resp.status < 400:
+ return True, f"{field_name} URL is reachable."
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
+ # Try GET as fallback — some servers reject HEAD
+ req2 = urllib.request.Request(url, method="GET")
+ req2.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
+ if token and "github.com" in url:
+ req2.add_header("Authorization", f"token {token}")
+ try:
+ with urllib.request.urlopen(req2, timeout=15) as resp2:
+ if resp2.status < 400:
+ return True, f"{field_name} URL is reachable."
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc2:
+ return False, (
+ f"{field_name} URL `{url}` is not reachable: {exc2}"
+ )
+ return False, f"{field_name} URL `{url}` returned an error."
+
+
+def validate_speckit_version(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "Required Spec Kit Version is required."
+ value = value.strip()
+ if not _VERSION_CONSTRAINT_RE.match(value):
+ return False, (
+ f"Spec Kit version constraint `{value}` looks invalid. "
+ "Use a PEP 440 constraint like `>=0.6.0`."
+ )
+ return True, f"Version constraint `{value}` is valid."
+
+
+def validate_commands_count(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "Number of Commands is required."
+ value = value.strip()
+ if not value.isdigit() or int(value) < 1:
+ return False, (
+ f"Number of Commands `{value}` must be a positive integer."
+ )
+ return True, f"Commands count: {value}."
+
+
+def validate_hooks_count(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return True, "Hooks count not provided (defaults to 0)."
+ value = value.strip()
+ if not value.isdigit():
+ return False, f"Number of Hooks `{value}` must be a non-negative integer."
+ return True, f"Hooks count: {value}."
+
+
+def validate_tags(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "Tags are required."
+ raw_tags = [t.strip().lower() for t in value.split(",") if t.strip()]
+ if len(raw_tags) < 2:
+ return False, "Please provide at least 2 tags."
+ if len(raw_tags) > 5:
+ return False, f"Too many tags ({len(raw_tags)}). Please provide 2-5 tags."
+ bad = [t for t in raw_tags if not re.match(r"^[a-z0-9-]+$", t)]
+ if bad:
+ return False, (
+ f"Tags must be lowercase alphanumeric with hyphens: {', '.join(bad)}"
+ )
+ return True, f"Tags: {', '.join(raw_tags)}."
+
+
+def validate_license(value: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, "License is required."
+ return True, f"License: {value.strip()}."
+
+
+def validate_required_text(value: str, field_name: str) -> tuple[bool, str]:
+ if not _present(value):
+ return False, f"{field_name} is required."
+ return True, f"{field_name} provided."
+
+
+def validate_checklist(value: str, field_name: str) -> tuple[bool, str]:
+ """Check that all checkboxes are ticked."""
+ if not _present(value):
+ return False, f"{field_name} is required."
+ lines = [l.strip() for l in value.splitlines() if l.strip().startswith("- [")]
+ unchecked = [l for l in lines if l.startswith("- [ ]")]
+ if unchecked:
+ items = "\n".join(f" - {l[5:].strip()}" for l in unchecked)
+ return False, (
+ f"The following {field_name} items are not checked:\n{items}"
+ )
+ return True, f"All {field_name} items confirmed."
+
+
+def _count_list_items(value: str) -> int:
+ """Count markdown list items (``- item``) in a textarea value."""
+ if not _present(value):
+ return 0
+ return sum(
+ 1 for line in value.splitlines()
+ if line.strip().startswith("- ") or line.strip().startswith("* ")
+ )
+
+
+# ---------------------------------------------------------------------------
+# Full validation pipeline
+# ---------------------------------------------------------------------------
+
+def validate_submission(
+ fields: dict[str, str],
+ catalog: dict,
+ catalog_type: str,
+ *,
+ check_urls: bool = True,
+ github_token: str | None = None,
+) -> tuple[bool, list[dict], bool]:
+ """Run all validators. Returns (all_passed, results_list, is_update)."""
+ results: list[dict] = []
+ label = catalog_type.title() # "Extension" or "Preset"
+
+ def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None:
+ results.append({
+ "field": field,
+ "ok": ok,
+ "message": msg,
+ "severity": "info" if ok else severity,
+ })
+
+ # --- ID ---
+ ok, msg, is_update = validate_item_id(
+ fields.get("item_id", ""), catalog, catalog_type,
+ )
+ _add(f"{label} ID", ok, msg)
+
+ # --- Name ---
+ ok, msg = validate_required_text(fields.get("item_name", ""), f"{label} Name")
+ _add(f"{label} Name", ok, msg)
+
+ # --- Version ---
+ item_id = fields.get("item_id", "").strip()
+ ok, msg = validate_version(
+ fields.get("version", ""),
+ is_update=is_update,
+ catalog=catalog,
+ item_id=item_id,
+ catalog_type=catalog_type,
+ )
+ _add("Version", ok, msg)
+
+ # --- Common fields ---
+ ok, msg = validate_description(fields.get("description", ""))
+ _add("Description", ok, msg)
+
+ ok, msg = validate_required_text(fields.get("author", ""), "Author")
+ _add("Author", ok, msg)
+
+ ok, msg = validate_license(fields.get("license", ""))
+ _add("License", ok, msg)
+
+ # --- URLs (common) ---
+ for url_field, url_label, required in [
+ ("repository", "Repository URL", True),
+ ("download_url", "Download URL", True),
+ ]:
+ ok, msg = validate_url(fields.get(url_field, ""), url_label, required=required)
+ _add(url_label, ok, msg)
+
+ # --- Extension-only URLs ---
+ if catalog_type == "extension":
+ for url_field, url_label in [
+ ("homepage", "Homepage"),
+ ("documentation", "Documentation URL"),
+ ("changelog", "Changelog URL"),
+ ]:
+ ok, msg = validate_url(
+ fields.get(url_field, ""), url_label, required=False,
+ )
+ _add(url_label, ok, msg)
+
+ # --- URL reachability ---
+ if check_urls:
+ for url_field, url_label in [
+ ("repository", "Repository URL"),
+ ("download_url", "Download URL"),
+ ]:
+ val = fields.get(url_field, "").strip()
+ if val and _URL_RE.match(val):
+ ok, msg = check_url_reachable(val, url_label, github_token)
+ _add(f"{url_label} (reachable)", ok, msg)
+
+ # --- Spec Kit version ---
+ ok, msg = validate_speckit_version(fields.get("speckit_version", ""))
+ _add("Required Spec Kit Version", ok, msg)
+
+ # --- Type-specific provides ---
+ if catalog_type == "extension":
+ ok, msg = validate_commands_count(fields.get("commands_count", ""))
+ _add("Number of Commands", ok, msg)
+
+ ok, msg = validate_hooks_count(fields.get("hooks_count", ""))
+ _add("Number of Hooks", ok, msg)
+ elif catalog_type == "preset":
+ ok, msg = validate_required_text(
+ fields.get("templates_provided", ""), "Templates Provided",
+ )
+ _add("Templates Provided", ok, msg)
+ # Commands Provided is optional for presets
+
+ # --- Tags ---
+ ok, msg = validate_tags(fields.get("tags", ""))
+ _add("Tags", ok, msg)
+
+ # --- Text fields ---
+ ok, msg = validate_required_text(fields.get("features", ""), "Key Features")
+ _add("Key Features", ok, msg)
+
+ if catalog_type == "extension":
+ ok, msg = validate_required_text(
+ fields.get("testing_details", ""), "Testing Details",
+ )
+ _add("Testing Details", ok, msg)
+
+ ok, msg = validate_required_text(
+ fields.get("example_usage", ""), "Example Usage",
+ )
+ _add("Example Usage", ok, msg)
+
+ # --- Checklists ---
+ ok, msg = validate_checklist(
+ fields.get("testing_checklist", ""), "Testing Checklist",
+ )
+ _add("Testing Checklist", ok, msg)
+
+ ok, msg = validate_checklist(
+ fields.get("requirements_checklist", ""), "Submission Requirements",
+ )
+ _add("Submission Requirements", ok, msg)
+
+ all_passed = all(r["ok"] for r in results)
+ return all_passed, results, is_update
+
+
+# ---------------------------------------------------------------------------
+# Catalog entry builder
+# ---------------------------------------------------------------------------
+
+def parse_tags(value: str) -> list[str]:
+ """Parse comma-separated tags into a sorted list."""
+ return sorted(
+ t.strip().lower()
+ for t in value.split(",")
+ if t.strip()
+ )
+
+
+def _clean(value: str | None) -> str:
+ """Return stripped value, or empty string if absent / GitHub placeholder."""
+ if not _present(value):
+ return ""
+ return value.strip()
+
+
+def build_catalog_entry(
+ fields: dict[str, str],
+ catalog_type: str,
+ catalog: dict | None = None,
+ is_update: bool = False,
+) -> dict:
+ """Build a catalog.community.json entry from validated fields.
+
+ On updates, preserves ``created_at``, ``downloads``, ``stars``, and
+ ``verified`` from the existing catalog entry.
+ """
+ if catalog_type == "preset":
+ return _build_preset_entry(fields, catalog=catalog, is_update=is_update)
+ return _build_extension_entry(fields, catalog=catalog, is_update=is_update)
+
+
+def _build_extension_entry(
+ fields: dict[str, str],
+ catalog: dict | None = None,
+ is_update: bool = False,
+) -> dict:
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+
+ hooks = 0
+ if _present(fields.get("hooks_count", "")):
+ hooks = int(fields["hooks_count"].strip())
+
+ repo = _clean(fields.get("repository"))
+ item_id = _clean(fields.get("item_id"))
+
+ existing: dict = {}
+ if is_update and catalog:
+ existing = catalog.get("extensions", {}).get(item_id, {})
+
+ return {
+ "name": _clean(fields.get("item_name")),
+ "id": item_id,
+ "description": _clean(fields.get("description")),
+ "author": _clean(fields.get("author")),
+ "version": _clean(fields.get("version")),
+ "download_url": _clean(fields.get("download_url")),
+ "repository": repo,
+ "homepage": _clean(fields.get("homepage")) or repo,
+ "documentation": (
+ _clean(fields.get("documentation"))
+ or repo + "/blob/main/README.md"
+ ),
+ "changelog": (
+ _clean(fields.get("changelog"))
+ or repo + "/blob/main/CHANGELOG.md"
+ ),
+ "license": fields["license"].strip(),
+ "requires": {
+ "speckit_version": fields["speckit_version"].strip(),
+ },
+ "provides": {
+ "commands": int(fields["commands_count"].strip()),
+ "hooks": hooks,
+ },
+ "tags": parse_tags(fields["tags"]),
+ "verified": existing.get("verified", False),
+ "downloads": existing.get("downloads", 0),
+ "stars": existing.get("stars", 0),
+ "created_at": existing.get("created_at", now),
+ "updated_at": now,
+ }
+
+
+def _build_preset_entry(
+ fields: dict[str, str],
+ catalog: dict | None = None,
+ is_update: bool = False,
+) -> dict:
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+
+ repo = _clean(fields.get("repository"))
+ item_id = _clean(fields.get("item_id"))
+
+ templates_count = _count_list_items(fields.get("templates_provided", ""))
+ commands_count = _count_list_items(fields.get("commands_provided", ""))
+
+ existing: dict = {}
+ if is_update and catalog:
+ existing = catalog.get("presets", {}).get(item_id, {})
+
+ return {
+ "name": _clean(fields.get("item_name")),
+ "id": item_id,
+ "version": _clean(fields.get("version")),
+ "description": _clean(fields.get("description")),
+ "author": _clean(fields.get("author")),
+ "repository": repo,
+ "download_url": _clean(fields.get("download_url")),
+ "homepage": repo,
+ "documentation": repo + "/blob/main/README.md",
+ "license": fields["license"].strip(),
+ "requires": {
+ "speckit_version": fields["speckit_version"].strip(),
+ },
+ "provides": {
+ "templates": templates_count,
+ "commands": commands_count,
+ },
+ "tags": parse_tags(fields["tags"]),
+ "created_at": existing.get("created_at", now),
+ "updated_at": now,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Report formatter
+# ---------------------------------------------------------------------------
+
+def format_report(
+ all_passed: bool,
+ results: list[dict],
+ entry: dict | None,
+ issue_number: str | None,
+ is_update: bool = False,
+ catalog_type: str = "extension",
+) -> str:
+ """Build a markdown validation report for the issue comment."""
+ lines: list[str] = []
+ action = "update" if is_update else "add"
+ label = catalog_type
+
+ if all_passed:
+ lines.append("## :white_check_mark: Submission Validated")
+ lines.append("")
+ lines.append(
+ f"All checks passed! A pull request will be created automatically "
+ f"to {action} this {label} in the community catalog."
+ )
+ else:
+ lines.append("## :x: Submission Needs Changes")
+ lines.append("")
+ lines.append(
+ "Some checks did not pass. Please edit this issue to fix "
+ "the items below, and validation will re-run automatically."
+ )
+
+ lines.append("")
+ lines.append("### Validation Results")
+ lines.append("")
+ lines.append("| Check | Status | Details |")
+ lines.append("|-------|--------|---------|")
+
+ for r in results:
+ icon = ":white_check_mark:" if r["ok"] else ":x:"
+ # Escape pipe characters in messages
+ msg = r["message"].replace("|", "\\|").replace("\n", " ")
+ lines.append(f"| {r['field']} | {icon} | {msg} |")
+
+ if all_passed and entry:
+ lines.append("")
+ lines.append("### Generated Catalog Entry")
+ lines.append("")
+ lines.append("")
+ lines.append("Click to expand JSON
")
+ lines.append("")
+ lines.append("```json")
+ lines.append(json.dumps({entry["id"]: entry}, indent=2))
+ lines.append("```")
+ lines.append("")
+ lines.append(" ")
+
+ lines.append("")
+ lines.append("---")
+ lines.append(
+ "*This comment was generated automatically by the catalog submission workflow.*"
+ )
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--catalog",
+ required=True,
+ help="Path to the catalog JSON file (e.g., extensions/catalog.community.json)",
+ )
+ parser.add_argument(
+ "--type",
+ choices=["extension", "preset"],
+ default="extension",
+ help="Catalog type",
+ )
+ parser.add_argument(
+ "--skip-url-checks",
+ action="store_true",
+ help="Skip HTTP reachability checks for URLs",
+ )
+ parser.add_argument(
+ "--output-report",
+ default="/tmp/validation-report.md",
+ help="Path to write the markdown report",
+ )
+ parser.add_argument(
+ "--output-entry",
+ default="/tmp/catalog-entry.json",
+ help="Path to write the generated catalog entry",
+ )
+ parser.add_argument(
+ "--output-result",
+ default="/tmp/validation-result.json",
+ help="Path to write the validation result metadata",
+ )
+ args = parser.parse_args()
+
+ # Read inputs
+ issue_body = os.environ.get("ISSUE_BODY", "")
+ if not issue_body:
+ print("Error: ISSUE_BODY environment variable is empty.", file=sys.stderr)
+ sys.exit(2)
+
+ issue_number = os.environ.get("ISSUE_NUMBER")
+ github_token = os.environ.get("GITHUB_TOKEN")
+
+ # Load catalog
+ catalog_path = Path(args.catalog)
+ if not catalog_path.exists():
+ print(f"Error: Catalog file not found: {catalog_path}", file=sys.stderr)
+ sys.exit(2)
+
+ with open(catalog_path) as f:
+ catalog = json.load(f)
+
+ # Parse and normalize
+ raw_fields = parse_issue_body(issue_body)
+ fields = normalize_fields(raw_fields, args.type)
+
+ if not fields:
+ print("Error: Could not parse any fields from the issue body.", file=sys.stderr)
+ sys.exit(2)
+
+ # Validate
+ all_passed, results, is_update = validate_submission(
+ fields,
+ catalog,
+ args.type,
+ check_urls=not args.skip_url_checks,
+ github_token=github_token,
+ )
+
+ # Build entry (even on failure, for debugging)
+ entry = None
+ try:
+ entry = build_catalog_entry(
+ fields, args.type, catalog=catalog, is_update=is_update,
+ )
+ except (KeyError, ValueError):
+ pass # Entry can't be built if required fields are missing
+
+ # Write report
+ report = format_report(
+ all_passed, results, entry, issue_number,
+ is_update=is_update, catalog_type=args.type,
+ )
+ Path(args.output_report).write_text(report)
+
+ # Write entry
+ if entry:
+ Path(args.output_entry).write_text(json.dumps(entry, indent=2))
+
+ # Write result metadata
+ item_id = fields.get("item_id", "").strip()
+ result_meta = {
+ "valid": all_passed,
+ "item_id": item_id,
+ "catalog_type": args.type,
+ "is_update": is_update,
+ "error_count": sum(1 for r in results if not r["ok"]),
+ "check_count": len(results),
+ }
+ Path(args.output_result).write_text(json.dumps(result_meta, indent=2))
+
+ # Set GitHub Actions outputs if available
+ gh_output = os.environ.get("GITHUB_OUTPUT")
+ if gh_output:
+ with open(gh_output, "a") as f:
+ f.write(f"valid={str(all_passed).lower()}\n")
+ f.write(f"item_id={item_id}\n")
+ f.write(f"catalog_type={args.type}\n")
+
+ print(
+ f"Validation {'PASSED' if all_passed else 'FAILED'}: "
+ f"{sum(1 for r in results if r['ok'])}/{len(results)} checks passed.",
+ )
+ # Always exit 0 so the workflow can post the comment
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml
new file mode 100644
index 0000000000..233486f9c3
--- /dev/null
+++ b/.github/workflows/catalog-pr.yml
@@ -0,0 +1,244 @@
+name: "Catalog: Create PR"
+
+on:
+ issues:
+ types: [labeled]
+
+concurrency:
+ group: catalog-pr-${{ github.event.issue.number }}
+ cancel-in-progress: true
+
+jobs:
+ create-extension-pr:
+ if: >
+ github.event.label.name == 'validated' &&
+ contains(github.event.issue.labels.*.name, 'extension-submission')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Parse and build entry
+ env:
+ ISSUE_BODY: ${{ github.event.issue.body }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python .github/scripts/catalog-validate.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+
+ - name: Update catalog
+ id: update
+ run: |
+ python .github/scripts/catalog-pr.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+
+ - name: Create or update pull request
+ if: steps.update.outputs.skipped != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
+ ITEM_ID: ${{ steps.update.outputs.item_id }}
+ ACTION: ${{ steps.update.outputs.action }}
+ ACTION_VERB: ${{ steps.update.outputs.action_verb }}
+ BRANCH: ${{ steps.update.outputs.branch }}
+ run: |
+ set -euo pipefail
+
+ # Check if branch already exists (from a previous run)
+ if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
+ git fetch origin "$BRANCH"
+ git checkout "$BRANCH"
+ git reset --hard origin/main
+ # Re-run the catalog update on the fresh branch
+ python .github/scripts/catalog-validate.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+ python .github/scripts/catalog-pr.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+ else
+ git checkout -b "$BRANCH"
+ fi
+
+ # Configure git
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ git add extensions/catalog.community.json
+ git commit -m "${ACTION} community extension: ${ITEM_ID}
+
+ Automated from issue #${ISSUE_NUMBER}.
+
+ Co-authored-by: ${ISSUE_AUTHOR} <${ISSUE_AUTHOR}@users.noreply.github.com>"
+
+ git push -u origin "$BRANCH" --force-with-lease
+
+ # Create or update PR
+ EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty')
+
+ PR_BODY="## Community Extension Submission
+
+ ${ACTION_VERB} **${ITEM_ID}** in the community extension catalog.
+
+ **Submitted by:** @${ISSUE_AUTHOR}
+ **Source issue:** #${ISSUE_NUMBER}
+
+ ### What this PR does
+
+ - ${ACTION_VERB} the entry in \`extensions/catalog.community.json\`
+ - All submission fields have been validated automatically
+
+ ### Reviewer checklist
+
+ - [ ] Entry looks correct in the catalog JSON
+ - [ ] Extension repository is accessible and contains valid code
+ - [ ] No concerns with the extension content
+
+ ---
+ *This PR was generated automatically from the issue submission. Approve and merge to publish.*"
+
+ if [ -n "$EXISTING_PR" ]; then
+ gh pr edit "$EXISTING_PR" \
+ --title "${ACTION} community extension: ${ITEM_ID}" \
+ --body "$PR_BODY"
+ else
+ gh pr create \
+ --title "${ACTION} community extension: ${ITEM_ID}" \
+ --body "$PR_BODY" \
+ --head "$BRANCH" \
+ --base main \
+ --assignee mnriem
+
+ NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
+ gh issue comment "$ISSUE_NUMBER" \
+ --body "Pull request #${NEW_PR} has been created for this submission. A maintainer will review it shortly."
+ fi
+
+ create-preset-pr:
+ if: >
+ github.event.label.name == 'validated' &&
+ contains(github.event.issue.labels.*.name, 'preset-submission')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Parse and build entry
+ env:
+ ISSUE_BODY: ${{ github.event.issue.body }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python .github/scripts/catalog-validate.py \
+ --catalog presets/catalog.community.json \
+ --type preset
+
+ - name: Update catalog and regenerate table
+ id: update
+ run: |
+ python .github/scripts/catalog-pr.py \
+ --catalog presets/catalog.community.json \
+ --type preset \
+ --table-target docs/community/presets.md
+
+ - name: Create or update pull request
+ if: steps.update.outputs.skipped != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
+ ITEM_ID: ${{ steps.update.outputs.item_id }}
+ ACTION: ${{ steps.update.outputs.action }}
+ ACTION_VERB: ${{ steps.update.outputs.action_verb }}
+ BRANCH: ${{ steps.update.outputs.branch }}
+ run: |
+ set -euo pipefail
+
+ # Check if branch already exists (from a previous run)
+ if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
+ git fetch origin "$BRANCH"
+ git checkout "$BRANCH"
+ git reset --hard origin/main
+ # Re-run on the fresh branch
+ python .github/scripts/catalog-validate.py \
+ --catalog presets/catalog.community.json \
+ --type preset
+ python .github/scripts/catalog-pr.py \
+ --catalog presets/catalog.community.json \
+ --type preset \
+ --table-target docs/community/presets.md
+ else
+ git checkout -b "$BRANCH"
+ fi
+
+ # Configure git
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ git add presets/catalog.community.json docs/community/presets.md
+ git commit -m "${ACTION} community preset: ${ITEM_ID}
+
+ Automated from issue #${ISSUE_NUMBER}.
+
+ Co-authored-by: ${ISSUE_AUTHOR} <${ISSUE_AUTHOR}@users.noreply.github.com>"
+
+ git push -u origin "$BRANCH" --force-with-lease
+
+ EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty')
+
+ PR_BODY="## Community Preset Submission
+
+ ${ACTION_VERB} **${ITEM_ID}** in the community preset catalog.
+
+ **Submitted by:** @${ISSUE_AUTHOR}
+ **Source issue:** #${ISSUE_NUMBER}
+
+ ### What this PR does
+
+ - ${ACTION_VERB} the entry in \`presets/catalog.community.json\`
+ - Auto-regenerates the table in \`docs/community/presets.md\`
+ - All submission fields have been validated automatically
+
+ ### Reviewer checklist
+
+ - [ ] Entry looks correct in the catalog JSON
+ - [ ] Preset repository is accessible and contains valid code
+ - [ ] No concerns with the preset content
+
+ ---
+ *This PR was generated automatically from the issue submission. Approve and merge to publish.*"
+
+ if [ -n "$EXISTING_PR" ]; then
+ gh pr edit "$EXISTING_PR" \
+ --title "${ACTION} community preset: ${ITEM_ID}" \
+ --body "$PR_BODY"
+ else
+ gh pr create \
+ --title "${ACTION} community preset: ${ITEM_ID}" \
+ --body "$PR_BODY" \
+ --head "$BRANCH" \
+ --base main \
+ --assignee mnriem
+
+ NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
+ gh issue comment "$ISSUE_NUMBER" \
+ --body "Pull request #${NEW_PR} has been created for this submission. A maintainer will review it shortly."
+ fi
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
new file mode 100644
index 0000000000..129578c7e0
--- /dev/null
+++ b/.github/workflows/catalog-validate.yml
@@ -0,0 +1,211 @@
+name: "Catalog: Validate Submission"
+
+on:
+ issues:
+ types: [opened, edited]
+
+concurrency:
+ group: catalog-validate-${{ github.event.issue.number }}
+ cancel-in-progress: true
+
+jobs:
+ validate-extension:
+ if: contains(github.event.issue.labels.*.name, 'extension-submission')
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ env:
+ RELEASE_PAT: ${{ secrets.RELEASE_PAT }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Validate submission
+ id: validate
+ env:
+ ISSUE_BODY: ${{ github.event.issue.body }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python .github/scripts/catalog-validate.py \
+ --catalog extensions/catalog.community.json \
+ --type extension
+
+ - name: Post validation comment and update labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.RELEASE_PAT }}
+ script: |
+ const fs = require('fs');
+ const report = fs.readFileSync('/tmp/validation-report.md', 'utf8');
+ const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
+
+ // Find existing bot comment to update (avoid spam)
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const marker = '';
+ const botComment = comments.data.find(c =>
+ c.user.type === 'Bot' && c.body.includes(marker)
+ );
+
+ const body = marker + '\n\n' + report;
+
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: body,
+ });
+ }
+
+ // Update labels — uses RELEASE_PAT so the label event
+ // triggers the catalog-pr workflow
+ const currentLabels = context.payload.issue.labels.map(l => l.name);
+
+ if (result.valid) {
+ if (currentLabels.includes('needs-changes')) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ name: 'needs-changes',
+ });
+ }
+ if (!currentLabels.includes('validated')) {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['validated'],
+ });
+ }
+ } else {
+ if (currentLabels.includes('validated')) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ name: 'validated',
+ });
+ }
+ if (!currentLabels.includes('needs-changes')) {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['needs-changes'],
+ });
+ }
+ }
+
+ validate-preset:
+ if: contains(github.event.issue.labels.*.name, 'preset-submission')
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Validate submission
+ id: validate
+ env:
+ ISSUE_BODY: ${{ github.event.issue.body }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python .github/scripts/catalog-validate.py \
+ --catalog presets/catalog.community.json \
+ --type preset
+
+ - name: Post validation comment and update labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.RELEASE_PAT }}
+ script: |
+ const fs = require('fs');
+ const report = fs.readFileSync('/tmp/validation-report.md', 'utf8');
+ const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
+
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const marker = '';
+ const botComment = comments.data.find(c =>
+ c.user.type === 'Bot' && c.body.includes(marker)
+ );
+
+ const body = marker + '\n\n' + report;
+
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: body,
+ });
+ }
+
+ const currentLabels = context.payload.issue.labels.map(l => l.name);
+
+ if (result.valid) {
+ if (currentLabels.includes('needs-changes')) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ name: 'needs-changes',
+ });
+ }
+ if (!currentLabels.includes('validated')) {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['validated'],
+ });
+ }
+ } else {
+ if (currentLabels.includes('validated')) {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ name: 'validated',
+ });
+ }
+ if (!currentLabels.includes('needs-changes')) {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['needs-changes'],
+ });
+ }
+ }
diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
index dfc1125228..2962457deb 100644
--- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
+++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
@@ -526,18 +526,7 @@ specify extension add --from https://github.com/.../spec-kit-my
### Option 3: Community Reference Catalog
-Submit to the community catalog for public discovery:
-
-1. **Fork** spec-kit repository
-2. **Add entry** to `extensions/catalog.community.json`
-3. **Update** the Community Extensions table in `README.md` with your extension
-4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
-5. **After merge**, your extension becomes available:
- - Users can browse `catalog.community.json` to discover your extension
- - Users copy the entry to their own `catalog.json`
- - Users install with: `specify extension add my-ext` (from their catalog)
-
-See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed submission instructions.
+Submit to the community catalog for public discovery. See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for instructions.
---
@@ -575,6 +564,14 @@ See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed
- **Test with multiple versions**: Ensure compatibility
- **Graceful degradation**: Handle missing features
+### Maintenance
+
+- **Respond to issues**: Address issues in a timely manner
+- **Keep dependencies updated**: Regularly check for updates
+- **Maintain a changelog**: Document changes in CHANGELOG.md
+- **Deprecation notices**: Give advance notice for breaking changes
+- **Use a permissive license**: MIT or Apache 2.0 recommended
+
---
## Example Extensions
diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md
index 1433738743..b26e617935 100644
--- a/extensions/EXTENSION-PUBLISHING-GUIDE.md
+++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md
@@ -1,133 +1,30 @@
# Extension Publishing Guide
-This guide explains how to publish your extension to the Spec Kit extension catalog, making it discoverable by `specify extension search`.
+This guide explains how to publish your extension to the Spec Kit community catalog, making it discoverable by `specify extension search`.
+
+For how to develop and test an extension, see the [Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md).
## Table of Contents
1. [Prerequisites](#prerequisites)
-2. [Prepare Your Extension](#prepare-your-extension)
-3. [Submit to Catalog](#submit-to-catalog)
-4. [Verification Process](#verification-process)
-5. [Release Workflow](#release-workflow)
-6. [Best Practices](#best-practices)
+2. [Submit a New Extension to the Community Catalog](#submit-a-new-extension-to-the-community-catalog)
+3. [Update Your Extension in the Community Catalog](#update-your-extension-in-the-community-catalog)
+4. [FAQ](#faq)
---
## Prerequisites
-Before publishing an extension, ensure you have:
-
-1. **Valid Extension**: A working extension with a valid `extension.yml` manifest
-2. **Git Repository**: Extension hosted on GitHub (or other public git hosting)
-3. **Documentation**: README.md with installation and usage instructions
-4. **License**: Open source license file (MIT, Apache 2.0, etc.)
-5. **Versioning**: Semantic versioning (e.g., 1.0.0)
-6. **Testing**: Extension tested on real projects
-
----
+Before publishing, ensure you have:
-## Prepare Your Extension
-
-### 1. Extension Structure
-
-Ensure your extension follows the standard structure:
-
-```text
-your-extension/
-├── extension.yml # Required: Extension manifest
-├── README.md # Required: Documentation
-├── LICENSE # Required: License file
-├── CHANGELOG.md # Recommended: Version history
-├── .gitignore # Recommended: Git ignore rules
-│
-├── commands/ # Extension commands
-│ ├── command1.md
-│ └── command2.md
-│
-├── config-template.yml # Config template (if needed)
-│
-└── docs/ # Additional documentation
- ├── usage.md
- └── examples/
-```
-
-### 2. extension.yml Validation
-
-Verify your manifest is valid:
-
-```yaml
-schema_version: "1.0"
-
-extension:
- id: "your-extension" # Unique lowercase-hyphenated ID
- name: "Your Extension Name" # Human-readable name
- version: "1.0.0" # Semantic version
- description: "Brief description (one sentence)"
- author: "Your Name or Organization"
- repository: "https://github.com/your-org/spec-kit-your-extension"
- license: "MIT"
- homepage: "https://github.com/your-org/spec-kit-your-extension"
-
-requires:
- speckit_version: ">=0.1.0" # Required spec-kit version
-
-provides:
- commands: # List all commands
- - name: "speckit.your-extension.command"
- file: "commands/command.md"
- description: "Command description"
-
-tags: # 2-5 relevant tags
- - "category"
- - "tool-name"
-```
-
-**Validation Checklist**:
-
-- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)
-- ✅ `version` follows semantic versioning (X.Y.Z)
-- ✅ `description` is concise (under 100 characters)
-- ✅ `repository` URL is valid and public
-- ✅ All command files exist in the extension directory
-- ✅ Tags are lowercase and descriptive
-
-### 3. Create GitHub Release
-
-Create a GitHub release for your extension version:
-
-```bash
-# Tag the release
-git tag v1.0.0
-git push origin v1.0.0
-
-# Create release on GitHub
-# Go to: https://github.com/your-org/spec-kit-your-extension/releases/new
-# - Tag: v1.0.0
-# - Title: v1.0.0 - Release Name
-# - Description: Changelog/release notes
-```
-
-The release archive URL will be:
-
-```text
-https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
-```
-
-### 4. Test Installation
-
-Test that users can install from your release:
-
-```bash
-# Test dev installation
-specify extension add --dev /path/to/your-extension
-
-# Test from GitHub archive
-specify extension add --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
-```
+1. **A working extension** — developed and tested per the [Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)
+2. **A GitHub release** — tagged with a semver version (e.g., `v1.0.0`)
+3. **A valid download URL** — the release archive URL for your tag
+4. **An open-source license** — MIT, Apache 2.0, etc.
---
-## Submit to Catalog
+## Submit a New Extension to the Community Catalog
### Understanding the Catalogs
@@ -135,331 +32,41 @@ Spec Kit uses a dual-catalog system. For details about how catalogs work, see th
**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`.
-### 1. Fork the spec-kit Repository
-
-```bash
-# Fork on GitHub
-# https://github.com/github/spec-kit/fork
-
-# Clone your fork
-git clone https://github.com/YOUR-USERNAME/spec-kit.git
-cd spec-kit
-```
-
-### 2. Add Extension to Community Catalog
-
-Edit `extensions/catalog.community.json` and add your extension:
-
-```json
-{
- "schema_version": "1.0",
- "updated_at": "2026-01-28T15:54:00Z",
- "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
- "extensions": {
- "your-extension": {
- "name": "Your Extension Name",
- "id": "your-extension",
- "description": "Brief description of your extension",
- "author": "Your Name",
- "version": "1.0.0",
- "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip",
- "repository": "https://github.com/your-org/spec-kit-your-extension",
- "homepage": "https://github.com/your-org/spec-kit-your-extension",
- "documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/",
- "changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md",
- "license": "MIT",
- "requires": {
- "speckit_version": ">=0.1.0",
- "tools": [
- {
- "name": "required-mcp-tool",
- "version": ">=1.0.0",
- "required": true
- }
- ]
- },
- "provides": {
- "commands": 3,
- "hooks": 1
- },
- "tags": [
- "category",
- "tool-name",
- "feature"
- ],
- "verified": false,
- "downloads": 0,
- "stars": 0,
- "created_at": "2026-01-28T00:00:00Z",
- "updated_at": "2026-01-28T00:00:00Z"
- }
- }
-}
-```
-
-**Important**:
-
-- Set `verified: false` (maintainers will verify)
-- Set `downloads: 0` and `stars: 0` (auto-updated later)
-- Use current timestamp for `created_at` and `updated_at`
-- Update the top-level `updated_at` to current time
-
-### 3. Update Community Extensions Table
-
-Add your extension to the Community Extensions table in the project root `README.md`:
-
-```markdown
-| Your Extension Name | Brief description of what it does | `` | | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
-```
-
-**(Table) Category** — pick the one that best fits your extension:
-
-- `docs` — reads, validates, or generates spec artifacts
-- `code` — reviews, validates, or modifies source code
-- `process` — orchestrates workflow across phases
-- `integration` — syncs with external platforms
-- `visibility` — reports on project health or progress
-
-**Effect** — choose one:
-
-- Read-only — produces reports without modifying files
-- Read+Write — modifies files, creates artifacts, or updates specs
-
-Insert your extension in alphabetical order in the table.
-
-### 4. Submit Pull Request
-
-```bash
-# Create a branch
-git checkout -b add-your-extension
-
-# Commit your changes
-git add extensions/catalog.community.json README.md
-git commit -m "Add your-extension to community catalog
-
-- Extension ID: your-extension
-- Version: 1.0.0
-- Author: Your Name
-- Description: Brief description
-"
-
-# Push to your fork
-git push origin add-your-extension
-
-# Create Pull Request on GitHub
-# https://github.com/github/spec-kit/compare
-```
-
-**Pull Request Template**:
-
-```markdown
-## Extension Submission
-
-**Extension Name**: Your Extension Name
-**Extension ID**: your-extension
-**Version**: 1.0.0
-**Author**: Your Name
-**Repository**: https://github.com/your-org/spec-kit-your-extension
-
-### Description
-Brief description of what your extension does.
-
-### Checklist
-- [x] Valid extension.yml manifest
-- [x] README.md with installation and usage docs
-- [x] LICENSE file included
-- [x] GitHub release created (v1.0.0)
-- [x] Extension tested on real project
-- [x] All commands working
-- [x] No security vulnerabilities
-- [x] Added to extensions/catalog.community.json
-- [x] Added to Community Extensions table in README.md
-
-### Testing
-Tested on:
-- macOS 13.0+ with spec-kit 0.1.0
-- Project: [Your test project]
-
-### Additional Notes
-Any additional context or notes for reviewers.
-```
-
----
-
-## Verification Process
-
-### What Happens After Submission
-
-1. **Automated Checks** (if available):
- - Manifest validation
- - Download URL accessibility
- - Repository existence
- - License file presence
-
-2. **Manual Review**:
- - Code quality review
- - Security audit
- - Functionality testing
- - Documentation review
-
-3. **Verification**:
- - If approved, `verified: true` is set
- - Extension appears in `specify extension search --verified`
-
-### Verification Criteria
-
-To be verified, your extension must:
-
-✅ **Functionality**:
-
-- Works as described in documentation
-- All commands execute without errors
-- No breaking changes to user workflows
-
-✅ **Security**:
-
-- No known vulnerabilities
-- No malicious code
-- Safe handling of user data
-- Proper validation of inputs
-
-✅ **Code Quality**:
-
-- Clean, readable code
-- Follows extension best practices
-- Proper error handling
-- Helpful error messages
-
-✅ **Documentation**:
-
-- Clear installation instructions
-- Usage examples
-- Troubleshooting section
-- Accurate description
-
-✅ **Maintenance**:
-
-- Active repository
-- Responsive to issues
-- Regular updates
-- Semantic versioning followed
-
-### Typical Review Timeline
-
-- **Automated checks**: Immediate (if implemented)
-- **Manual review**: 3-7 business days
-- **Verification**: After successful review
-
----
-
-## Release Workflow
-
-### Publishing New Versions
-
-When releasing a new version:
+### File an Extension Submission Issue
-1. **Update version** in `extension.yml`:
+Submit your extension by opening an issue using the **Extension Submission** template:
- ```yaml
- extension:
- version: "1.1.0" # Updated version
- ```
+1. Go to [New Issue → Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)
+2. Fill in all required fields (ID, name, version, description, URLs, etc.)
+3. Complete the testing and submission checklists
+4. Submit the issue
-2. **Update CHANGELOG.md**:
+### What Happens Next
- ```markdown
- ## [1.1.0] - 2026-02-15
+1. **Automated validation** runs immediately — the bot posts a comment showing which checks passed or failed:
+ - Extension ID format (lowercase with hyphens)
+ - Version is valid semver
+ - Description length (under 200 characters)
+ - Required URLs are present and reachable
+ - Spec Kit version constraint is valid
+ - Tags format and count (2-5 lowercase tags)
+ - All required fields and checklists are completed
+2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically.
+3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that:
+ - Adds your extension to `extensions/catalog.community.json` (alphabetically sorted)
+ - Is assigned to a maintainer for review
+4. A maintainer reviews the generated catalog entry and merges the PR
- ### Added
- - New feature X
+You do **not** need to fork the repository, edit JSON files, or create a PR manually.
- ### Fixed
- - Bug fix Y
- ```
-
-3. **Create GitHub release**:
-
- ```bash
- git tag v1.1.0
- git push origin v1.1.0
- # Create release on GitHub
- ```
-
-4. **Update catalog**:
-
- ```bash
- # Fork spec-kit repo (or update existing fork)
- cd spec-kit
-
- # Update extensions/catalog.json
- jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
- jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
- jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
- jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
-
- # Submit PR
- git checkout -b update-your-extension-v1.1.0
- git add extensions/catalog.json
- git commit -m "Update your-extension to v1.1.0"
- git push origin update-your-extension-v1.1.0
- ```
-
-5. **Submit update PR** with changelog in description
+> [!IMPORTANT]
+> Maintainers validate that the **catalog metadata is correct** — they do **not** review, audit, or test the extension code itself. Users should review extension source code before installation.
---
-## Best Practices
-
-### Extension Design
-
-1. **Single Responsibility**: Each extension should focus on one tool/integration
-2. **Clear Naming**: Use descriptive, unambiguous names
-3. **Minimal Dependencies**: Avoid unnecessary dependencies
-4. **Backward Compatibility**: Follow semantic versioning strictly
-
-### Documentation
-
-1. **README.md Structure**:
- - Overview and features
- - Installation instructions
- - Configuration guide
- - Usage examples
- - Troubleshooting
- - Contributing guidelines
-
-2. **Command Documentation**:
- - Clear description
- - Prerequisites listed
- - Step-by-step instructions
- - Error handling guidance
- - Examples
-
-3. **Configuration**:
- - Provide template file
- - Document all options
- - Include examples
- - Explain defaults
-
-### Security
-
-1. **Input Validation**: Validate all user inputs
-2. **No Hardcoded Secrets**: Never include credentials
-3. **Safe Dependencies**: Only use trusted dependencies
-4. **Audit Regularly**: Check for vulnerabilities
+## Update Your Extension in the Community Catalog
-### Maintenance
-
-1. **Respond to Issues**: Address issues within 1-2 weeks
-2. **Regular Updates**: Keep dependencies updated
-3. **Changelog**: Maintain detailed changelog
-4. **Deprecation**: Give advance notice for breaking changes
-
-### Community
-
-1. **License**: Use permissive open-source license (MIT, Apache 2.0)
-2. **Contributing**: Welcome contributions
-3. **Code of Conduct**: Be respectful and inclusive
-4. **Support**: Provide ways to get help (issues, discussions, email)
+After publishing a new release in your extension's repository, file a new [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) issue with the updated version, download URL, and any other changed fields. The automation detects existing entries and processes them as updates (the new version must be higher than the current one).
---
@@ -467,27 +74,15 @@ When releasing a new version:
### Q: Can I publish private/proprietary extensions?
-A: The main catalog is for public extensions only. For private extensions:
-
-- Host your own catalog.json file
-- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json`
-- Not yet implemented - coming in Phase 4
+A: The community catalog is for public extensions only. For private extensions, install directly with `--dev` or `--from` and keep private.
-### Q: How long does verification take?
+### Q: What if my submission fails validation?
-A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster.
-
-### Q: What if my extension is rejected?
-
-A: You'll receive feedback on what needs to be fixed. Make the changes and resubmit.
+A: The bot posts a detailed comment showing which checks failed and what to fix. Edit the issue to correct the fields and validation re-runs automatically.
### Q: Can I update my extension anytime?
-A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes.
-
-### Q: Do I need to be verified to be in the catalog?
-
-A: No, unverified extensions are still searchable. Verification just adds trust and visibility.
+A: Yes. File a new Extension Submission issue with the updated version and fields. The automation detects that the extension already exists and processes it as an update.
### Q: Can extensions have paid features?
@@ -495,67 +90,6 @@ A: Extensions should be free and open-source. Commercial support/services are al
---
-## Support
-
-- **Catalog Issues**:
-- **Extension Template**: (coming soon)
-- **Development Guide**: See EXTENSION-DEVELOPMENT-GUIDE.md
-- **Community**: Discussions and Q&A
-
----
-
-## Appendix: Catalog Schema
-
-### Complete Catalog Entry Schema
-
-```json
-{
- "name": "string (required)",
- "id": "string (required, unique)",
- "description": "string (required, <200 chars)",
- "author": "string (required)",
- "version": "string (required, semver)",
- "download_url": "string (required, valid URL)",
- "repository": "string (required, valid URL)",
- "homepage": "string (optional, valid URL)",
- "documentation": "string (optional, valid URL)",
- "changelog": "string (optional, valid URL)",
- "license": "string (required)",
- "requires": {
- "speckit_version": "string (required, version specifier)",
- "tools": [
- {
- "name": "string (required)",
- "version": "string (optional, version specifier)",
- "required": "boolean (default: false)"
- }
- ]
- },
- "provides": {
- "commands": "integer (optional)",
- "hooks": "integer (optional)"
- },
- "tags": ["array of strings (2-10 tags)"],
- "verified": "boolean (default: false)",
- "downloads": "integer (auto-updated)",
- "stars": "integer (auto-updated)",
- "created_at": "string (ISO 8601 datetime)",
- "updated_at": "string (ISO 8601 datetime)"
-}
-```
-
-### Valid Tags
-
-Recommended tag categories:
-
-- **Integration**: jira, linear, github, gitlab, azure-devops
-- **Category**: issue-tracking, vcs, ci-cd, documentation, testing
-- **Platform**: atlassian, microsoft, google
-- **Feature**: automation, reporting, deployment, monitoring
-
-Use 2-5 tags that best describe your extension.
-
----
-
-*Last Updated: 2026-01-28*
-*Catalog Format Version: 1.0*
+- **Catalog Issues**:
+- **Development Guide**: See [EXTENSION-DEVELOPMENT-GUIDE.md](EXTENSION-DEVELOPMENT-GUIDE.md)
+- **Community**: [Discussions](https://github.com/github/spec-kit/discussions)
diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md
index c3391dbc75..df8badb18b 100644
--- a/extensions/EXTENSION-USER-GUIDE.md
+++ b/extensions/EXTENSION-USER-GUIDE.md
@@ -984,7 +984,7 @@ After creating tasks, sync to Jira:
### Q: How do I know if an extension is safe?
-**A**: Look for the ✓ Verified badge. Verified extensions are reviewed by maintainers. Always review extension code before installing.
+**A**: Community extensions are **not** reviewed, audited, or tested by maintainers — only their catalog metadata is validated. Always review extension source code before installing.
### Q: Can extensions modify spec-kit core?
diff --git a/extensions/README.md b/extensions/README.md
index f535ba539a..849014fa4b 100644
--- a/extensions/README.md
+++ b/extensions/README.md
@@ -89,10 +89,9 @@ To add your extension to the community catalog:
1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)
2. **Create a GitHub release** for your extension
-3. **Submit a Pull Request** that:
- - Adds your extension to `extensions/catalog.community.json`
- - Updates this README with your extension in the Available Extensions table
-4. **Wait for review** - maintainers will review and merge if criteria are met
+3. **Open an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template
+4. **Automated validation** checks your submission and a PR is created automatically once all checks pass
+5. **Wait for review** — a maintainer reviews and merges the PR
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions.
diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md
index 77a50d4d98..b369f7259e 100644
--- a/integrations/CONTRIBUTING.md
+++ b/integrations/CONTRIBUTING.md
@@ -91,6 +91,8 @@ provides:
### Submitting to the Community Catalog
+> **Note**: Automated submission via issue templates is planned. For now, submit manually using the steps below.
+
1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit)
2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`:
diff --git a/presets/DEVELOPING.md b/presets/DEVELOPING.md
new file mode 100644
index 0000000000..2015d959f7
--- /dev/null
+++ b/presets/DEVELOPING.md
@@ -0,0 +1,180 @@
+# Preset Development Guide
+
+A guide for creating Spec Kit presets. Presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling.
+
+## Table of Contents
+
+1. [Preset Structure](#preset-structure)
+2. [Manifest Validation](#manifest-validation)
+3. [Testing](#testing)
+4. [Creating a Release](#creating-a-release)
+5. [Best Practices](#best-practices)
+
+---
+
+## Preset Structure
+
+Ensure your preset follows the standard structure:
+
+```text
+your-preset/
+├── preset.yml # Required: Preset manifest
+├── README.md # Required: Documentation
+├── LICENSE # Required: License file
+├── CHANGELOG.md # Recommended: Version history
+│
+├── templates/ # Template overrides
+│ ├── spec-template.md
+│ ├── plan-template.md
+│ └── ...
+│
+└── commands/ # Command overrides (optional)
+ └── speckit.specify.md
+```
+
+Start from the [scaffold](scaffold/) if you're creating a new preset.
+
+---
+
+## Manifest Validation
+
+Verify your `preset.yml` is valid:
+
+```yaml
+schema_version: "1.0"
+
+preset:
+ id: "your-preset" # Unique lowercase-hyphenated ID
+ name: "Your Preset Name" # Human-readable name
+ version: "1.0.0" # Semantic version
+ description: "Brief description (one sentence)"
+ author: "Your Name or Organization"
+ repository: "https://github.com/your-org/spec-kit-preset-your-preset"
+ license: "MIT"
+
+requires:
+ speckit_version: ">=0.1.0" # Required spec-kit version
+
+provides:
+ templates:
+ - type: "template"
+ name: "spec-template"
+ file: "templates/spec-template.md"
+ description: "Custom spec template"
+ replaces: "spec-template"
+
+tags: # 2-5 relevant tags
+ - "category"
+ - "workflow"
+```
+
+**Validation Checklist**:
+
+- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)
+- ✅ `version` follows semantic versioning (X.Y.Z)
+- ✅ `description` is concise (under 200 characters)
+- ✅ `repository` URL is valid and public
+- ✅ All template and command files exist in the preset directory
+- ✅ Template names are lowercase with hyphens only
+- ✅ Command names use dot notation (e.g. `speckit.specify`)
+- ✅ Tags are lowercase and descriptive
+
+---
+
+## Testing
+
+### Local Testing
+
+```bash
+# Install from local directory
+specify preset add --dev /path/to/your-preset
+
+# Verify templates resolve from your preset
+specify preset resolve spec-template
+
+# Verify preset info
+specify preset info your-preset
+
+# List installed presets
+specify preset list
+
+# Remove when done testing
+specify preset remove your-preset
+```
+
+### Verify Command Registration
+
+If your preset includes command overrides, verify they appear in the agent directories:
+
+```bash
+# Check Claude commands (if using Claude)
+ls .claude/commands/speckit.*.md
+
+# Check Copilot commands (if using Copilot)
+ls .github/agents/speckit.*.agent.md
+
+# Check Gemini commands (if using Gemini)
+ls .gemini/commands/speckit.*.toml
+```
+
+---
+
+## Creating a Release
+
+Create a GitHub release for your preset version:
+
+```bash
+# Tag the release
+git tag v1.0.0
+git push origin v1.0.0
+```
+
+The release archive URL will be:
+
+```text
+https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
+```
+
+### Test Installation from Archive
+
+```bash
+specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
+```
+
+---
+
+## Best Practices
+
+### Template Design
+
+- **Keep sections clear** — use headings and placeholder text the LLM can replace
+- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template
+- **Document customization points** — use HTML comments to guide users on what to change
+
+### Naming
+
+- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean`
+- Avoid generic names: `my-preset`, `custom`, `test`
+
+### Stacking
+
+- Design presets to work well when stacked with others
+- Only override templates you need to change
+- Document which templates and commands your preset modifies
+
+### Command Overrides
+
+- Only override commands when the workflow needs to change, not just the output format
+- If you only need different template sections, a template override is sufficient
+- Test command overrides with multiple agents (Claude, Gemini, Copilot)
+
+### Maintenance
+
+- **Respond to issues**: Address issues in a timely manner
+- **Keep a changelog**: Document changes in CHANGELOG.md
+- **Deprecation notices**: Give advance notice for breaking changes
+- **Use a permissive license**: MIT or Apache 2.0 recommended
+
+---
+
+When your preset is ready to share, see the [Publishing Guide](PUBLISHING.md) to submit it to the community catalog.
diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md
index 661614e5c0..ef670a0619 100644
--- a/presets/PUBLISHING.md
+++ b/presets/PUBLISHING.md
@@ -1,155 +1,30 @@
# Preset Publishing Guide
-This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`.
+This guide explains how to publish your preset to the Spec Kit community catalog, making it discoverable by `specify preset search`.
+
+For how to develop and test a preset, see the [Development Guide](DEVELOPING.md).
## Table of Contents
1. [Prerequisites](#prerequisites)
-2. [Prepare Your Preset](#prepare-your-preset)
-3. [Submit to Catalog](#submit-to-catalog)
-4. [Verification Process](#verification-process)
-5. [Release Workflow](#release-workflow)
-6. [Best Practices](#best-practices)
+2. [Submit a New Preset to the Community Catalog](#submit-a-new-preset-to-the-community-catalog)
+3. [Update Your Preset in the Community Catalog](#update-your-preset-in-the-community-catalog)
+4. [FAQ](#faq)
---
## Prerequisites
-Before publishing a preset, ensure you have:
+Before publishing, ensure you have:
-1. **Valid Preset**: A working preset with a valid `preset.yml` manifest
-2. **Git Repository**: Preset hosted on GitHub (or other public git hosting)
-3. **Documentation**: README.md with description and usage instructions
-4. **License**: Open source license file (MIT, Apache 2.0, etc.)
-5. **Versioning**: Semantic versioning (e.g., 1.0.0)
-6. **Testing**: Preset tested on real projects with `specify preset add --dev`
+1. **A working preset** — developed and tested per the [Development Guide](DEVELOPING.md)
+2. **A GitHub release** — tagged with a semver version (e.g., `v1.0.0`)
+3. **A valid download URL** — the release archive URL for your tag
+4. **An open-source license** — MIT, Apache 2.0, etc.
---
-## Prepare Your Preset
-
-### 1. Preset Structure
-
-Ensure your preset follows the standard structure:
-
-```text
-your-preset/
-├── preset.yml # Required: Preset manifest
-├── README.md # Required: Documentation
-├── LICENSE # Required: License file
-├── CHANGELOG.md # Recommended: Version history
-│
-├── templates/ # Template overrides
-│ ├── spec-template.md
-│ ├── plan-template.md
-│ └── ...
-│
-└── commands/ # Command overrides (optional)
- └── speckit.specify.md
-```
-
-Start from the [scaffold](scaffold/) if you're creating a new preset.
-
-### 2. preset.yml Validation
-
-Verify your manifest is valid:
-
-```yaml
-schema_version: "1.0"
-
-preset:
- id: "your-preset" # Unique lowercase-hyphenated ID
- name: "Your Preset Name" # Human-readable name
- version: "1.0.0" # Semantic version
- description: "Brief description (one sentence)"
- author: "Your Name or Organization"
- repository: "https://github.com/your-org/spec-kit-preset-your-preset"
- license: "MIT"
-
-requires:
- speckit_version: ">=0.1.0" # Required spec-kit version
-
-provides:
- templates:
- - type: "template"
- name: "spec-template"
- file: "templates/spec-template.md"
- description: "Custom spec template"
- replaces: "spec-template"
-
-tags: # 2-5 relevant tags
- - "category"
- - "workflow"
-```
-
-**Validation Checklist**:
-
-- ✅ `id` is lowercase with hyphens only (no underscores, spaces, or special characters)
-- ✅ `version` follows semantic versioning (X.Y.Z)
-- ✅ `description` is concise (under 200 characters)
-- ✅ `repository` URL is valid and public
-- ✅ All template and command files exist in the preset directory
-- ✅ Template names are lowercase with hyphens only
-- ✅ Command names use dot notation (e.g. `speckit.specify`)
-- ✅ Tags are lowercase and descriptive
-
-### 3. Test Locally
-
-```bash
-# Install from local directory
-specify preset add --dev /path/to/your-preset
-
-# Verify templates resolve from your preset
-specify preset resolve spec-template
-
-# Verify preset info
-specify preset info your-preset
-
-# List installed presets
-specify preset list
-
-# Remove when done testing
-specify preset remove your-preset
-```
-
-If your preset includes command overrides, verify they appear in the agent directories:
-
-```bash
-# Check Claude commands (if using Claude)
-ls .claude/commands/speckit.*.md
-
-# Check Copilot commands (if using Copilot)
-ls .github/agents/speckit.*.agent.md
-
-# Check Gemini commands (if using Gemini)
-ls .gemini/commands/speckit.*.toml
-```
-
-### 4. Create GitHub Release
-
-Create a GitHub release for your preset version:
-
-```bash
-# Tag the release
-git tag v1.0.0
-git push origin v1.0.0
-```
-
-The release archive URL will be:
-
-```text
-https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
-```
-
-### 5. Test Installation from Archive
-
-```bash
-specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
-```
-
----
-
-## Submit to Catalog
+## Submit a New Preset to the Community Catalog
### Understanding the Catalogs
@@ -160,147 +35,54 @@ Spec Kit uses a dual-catalog system:
All community presets should be submitted to `catalog.community.json`.
-### 1. Fork the spec-kit Repository
-
-```bash
-git clone https://github.com/YOUR-USERNAME/spec-kit.git
-cd spec-kit
-```
-
-### 2. Add Preset to Community Catalog
-
-Edit `presets/catalog.community.json` and add your preset.
-
-> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object.
-
-```json
-{
- "schema_version": "1.0",
- "updated_at": "2026-03-10T00:00:00Z",
- "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
- "presets": {
- "your-preset": {
- "name": "Your Preset Name",
- "description": "Brief description of what your preset provides",
- "author": "Your Name",
- "version": "1.0.0",
- "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
- "repository": "https://github.com/your-org/spec-kit-preset-your-preset",
- "license": "MIT",
- "requires": {
- "speckit_version": ">=0.1.0"
- },
- "provides": {
- "templates": 3,
- "commands": 1
- },
- "tags": [
- "category",
- "workflow"
- ],
- "created_at": "2026-03-10T00:00:00Z",
- "updated_at": "2026-03-10T00:00:00Z"
- }
- }
-}
-```
-
-### 3. Update Community Presets Table
-
-Add your preset to the Community Presets table on the docs site at `docs/community/presets.md`:
-
-```markdown
-| Your Preset Name | Brief description of what your preset does | N templates, M commands[, P scripts] | — | [repo-name](https://github.com/your-org/spec-kit-preset-your-preset) |
-```
+### File a Preset Submission Issue
-Insert your row in alphabetical order by preset **name** (the first column of the table).
+Submit your preset by opening an issue using the **Preset Submission** template:
-### 4. Submit Pull Request
+1. Go to [New Issue → Preset Submission](https://github.com/github/spec-kit/issues/new?template=preset_submission.yml)
+2. Fill in all required fields (ID, name, version, description, URLs, etc.)
+3. List the templates and commands your preset provides
+4. Complete the testing and submission checklists
+5. Submit the issue
-```bash
-git checkout -b add-your-preset
-git add presets/catalog.community.json docs/community/presets.md
-git commit -m "Add your-preset to community catalog
+### What Happens Next
-- Preset ID: your-preset
-- Version: 1.0.0
-- Author: Your Name
-- Description: Brief description
-"
-git push origin add-your-preset
-```
+1. **Automated validation** runs immediately — the bot posts a comment showing which checks passed or failed:
+ - Preset ID format (lowercase with hyphens)
+ - Version is valid semver
+ - Description length (under 200 characters)
+ - Required URLs are present and reachable
+ - Spec Kit version constraint is valid
+ - Tags format and count (2-5 lowercase tags)
+ - All required fields and checklists are completed
+2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically.
+3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that:
+ - Adds your preset to `presets/catalog.community.json` (alphabetically sorted)
+ - Regenerates the community presets table in the docs
+ - Is assigned to a maintainer for review
+4. A maintainer reviews the generated catalog entry and merges the PR
-**Pull Request Checklist**:
+You do **not** need to fork the repository, edit JSON files, or create a PR manually.
-```markdown
-## Preset Submission
+> [!IMPORTANT]
+> Maintainers validate that the **catalog metadata is correct** — they do **not** review, audit, or test the preset code itself. Users should review preset source code before installation.
-**Preset Name**: Your Preset Name
-**Preset ID**: your-preset
-**Version**: 1.0.0
-**Repository**: https://github.com/your-org/spec-kit-preset-your-preset
+## Update Your Preset in the Community Catalog
-### Checklist
-- [ ] Valid preset.yml manifest
-- [ ] README.md with description and usage
-- [ ] LICENSE file included
-- [ ] GitHub release created
-- [ ] Preset tested with `specify preset add --dev`
-- [ ] Templates resolve correctly (`specify preset resolve`)
-- [ ] Commands register to agent directories (if applicable)
-- [ ] Commands match template sections (command + template are coherent)
-- [ ] Added to presets/catalog.community.json
-- [ ] Added row to docs/community/presets.md table
-```
+After publishing a new release in your preset's repository, file a new [Preset Submission](https://github.com/github/spec-kit/issues/new?template=preset_submission.yml) issue with the updated version and download URL. The automation detects existing entries and processes them as updates (the new version must be higher than the current one).
---
-## Verification Process
-
-After submission, maintainers will review:
-
-1. **Manifest validation** — valid `preset.yml`, all files exist
-2. **Template quality** — templates are useful and well-structured
-3. **Command coherence** — commands reference sections that exist in templates
-4. **Security** — no malicious content, safe file operations
-5. **Documentation** — clear README explaining what the preset does
-
-Once verified, `verified: true` is set and the preset appears in `specify preset search`.
-
----
-
-## Release Workflow
-
-When releasing a new version:
-
-1. Update `version` in `preset.yml`
-2. Update CHANGELOG.md
-3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0`
-4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json`
-
----
-
-## Best Practices
-
-### Template Design
-
-- **Keep sections clear** — use headings and placeholder text the LLM can replace
-- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template
-- **Document customization points** — use HTML comments to guide users on what to change
+## FAQ
-### Naming
+### Q: Can I publish private/proprietary presets?
-- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean`
-- Avoid generic names: `my-preset`, `custom`, `test`
+A: The community catalog is for public presets only. For private presets, install directly with `--dev` or `--from` and keep private.
-### Stacking
+### Q: What if my submission fails validation?
-- Design presets to work well when stacked with others
-- Only override templates you need to change
-- Document which templates and commands your preset modifies
+A: The bot posts a detailed comment showing which checks failed and what to fix. Edit the issue to correct the fields and validation re-runs automatically.
-### Command Overrides
+### Q: Can I update my preset anytime?
-- Only override commands when the workflow needs to change, not just the output format
-- If you only need different template sections, a template override is sufficient
-- Test command overrides with multiple agents (Claude, Gemini, Copilot)
+A: Yes. File a new Preset Submission issue with the updated version and fields. The automation detects that the preset already exists and processes it as an update.
From 02872e78edad52999ba4b6bb9758645d1f28e3a5 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 07:09:27 -0500
Subject: [PATCH 02/13] fix: use URL hostname allowlist instead of substring
match for token auth
Parse the URL with urllib.parse.urlparse and check the hostname against
an explicit allowlist (github.com, www.github.com, codeload.github.com,
raw.githubusercontent.com) before attaching the Authorization header.
This prevents leaking the GitHub token to attacker-controlled domains
that contain 'github.com' as a substring (e.g. evilgithub.com).
Addresses CodeQL incomplete-URL-substring-sanitization finding.
---
.github/scripts/catalog-validate.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 03435052ac..ab477da0b3 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -24,6 +24,7 @@
import re
import sys
import urllib.error
+import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
@@ -239,9 +240,11 @@ def check_url_reachable(
if not _present(url):
return True, "" # skip if empty/optional
url = url.strip()
+ _gh_hosts = {"github.com", "www.github.com", "codeload.github.com", "raw.githubusercontent.com"}
+ _is_github = urllib.parse.urlparse(url).hostname in _gh_hosts
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
- if token and "github.com" in url:
+ if token and _is_github:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
@@ -251,7 +254,7 @@ def check_url_reachable(
# Try GET as fallback — some servers reject HEAD
req2 = urllib.request.Request(url, method="GET")
req2.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
- if token and "github.com" in url:
+ if token and _is_github:
req2.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req2, timeout=15) as resp2:
From 4f1b42aca0f382b5df48b4c789df871efd869b39 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 08:05:56 -0500
Subject: [PATCH 03/13] fix: address Copilot review findings from PR #2401
- SSRF protection: reject private/loopback/reserved IPs and non-HTTP(S)
schemes in check_url_reachable() before making network requests
- Table generator: exit non-zero when --target is set but markers are
missing, so CI fails loudly instead of silently skipping the update
- Add catalog-table-start/end markers to docs/community/presets.md so
the table generator can update it automatically
- Use RELEASE_PAT instead of GITHUB_TOKEN in catalog-pr.yml so
auto-generated PRs trigger downstream CI workflows
- Reword extension safety FAQ to distinguish verified vs unverified
community extensions
---
.github/scripts/catalog-generate-table.py | 6 +++---
.github/scripts/catalog-validate.py | 23 ++++++++++++++++++++++-
.github/workflows/catalog-pr.yml | 8 ++++++--
docs/community/presets.md | 2 ++
extensions/EXTENSION-USER-GUIDE.md | 2 +-
5 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py
index eafb5c361e..fe7714d271 100644
--- a/.github/scripts/catalog-generate-table.py
+++ b/.github/scripts/catalog-generate-table.py
@@ -181,11 +181,11 @@ def main() -> None:
print(f"Updated {target}")
else:
print(
- f"Warning: markers {START_MARKER} / {END_MARKER} not found "
- f"in {target}. Printing table to stdout.",
+ f"Error: markers {START_MARKER} / {END_MARKER} not found "
+ f"in {target}.",
file=sys.stderr,
)
- print(table)
+ sys.exit(1)
else:
print(table)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index ab477da0b3..4a58aa2c6a 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -19,9 +19,11 @@
from __future__ import annotations
import argparse
+import ipaddress
import json
import os
import re
+import socket
import sys
import urllib.error
import urllib.parse
@@ -240,8 +242,27 @@ def check_url_reachable(
if not _present(url):
return True, "" # skip if empty/optional
url = url.strip()
+
+ # --- SSRF guard: reject non-HTTPS, private/loopback IPs ---
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ return False, f"{field_name} URL must use http or https scheme."
+ hostname = parsed.hostname
+ if not hostname:
+ return False, f"{field_name} URL has no hostname."
+ try:
+ addr_info = socket.getaddrinfo(hostname, None)
+ for _family, _type, _proto, _canonname, sockaddr in addr_info:
+ ip = ipaddress.ip_address(sockaddr[0])
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
+ return False, (
+ f"{field_name} URL `{url}` resolves to a private/reserved address."
+ )
+ except (socket.gaierror, ValueError):
+ pass # DNS resolution may fail for unreachable hosts — let urlopen handle it
+
_gh_hosts = {"github.com", "www.github.com", "codeload.github.com", "raw.githubusercontent.com"}
- _is_github = urllib.parse.urlparse(url).hostname in _gh_hosts
+ _is_github = hostname in _gh_hosts
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
if token and _is_github:
diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml
index 233486f9c3..24e6c7d314 100644
--- a/.github/workflows/catalog-pr.yml
+++ b/.github/workflows/catalog-pr.yml
@@ -20,6 +20,8 @@ jobs:
issues: write
steps:
- uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-python@v5
with:
@@ -45,7 +47,7 @@ jobs:
- name: Create or update pull request
if: steps.update.outputs.skipped != 'true'
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
ITEM_ID: ${{ steps.update.outputs.item_id }}
@@ -136,6 +138,8 @@ jobs:
issues: write
steps:
- uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-python@v5
with:
@@ -162,7 +166,7 @@ jobs:
- name: Create or update pull request
if: steps.update.outputs.skipped != 'true'
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
ITEM_ID: ${{ steps.update.outputs.item_id }}
diff --git a/docs/community/presets.md b/docs/community/presets.md
index 03ac777b80..9bf958b677 100644
--- a/docs/community/presets.md
+++ b/docs/community/presets.md
@@ -5,6 +5,7 @@
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json):
+
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
@@ -18,5 +19,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
+
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md
index df8badb18b..6b59bc07d4 100644
--- a/extensions/EXTENSION-USER-GUIDE.md
+++ b/extensions/EXTENSION-USER-GUIDE.md
@@ -984,7 +984,7 @@ After creating tasks, sync to Jira:
### Q: How do I know if an extension is safe?
-**A**: Community extensions are **not** reviewed, audited, or tested by maintainers — only their catalog metadata is validated. Always review extension source code before installing.
+**A**: Spec Kit supports both **verified** extensions and unverified community extensions. Verified extensions are reviewed and approved according to project policy, and are identified in the catalog/CLI with the verified flag, `--verified` filter, or "✓ Verified" badge. Unverified community extensions have only their catalog metadata validated; they are not reviewed, audited, or tested by maintainers. Always review extension source code before installing an unverified extension.
### Q: Can extensions modify spec-kit core?
From a8b702511a944ab04c8937f6b5aa81a7fb1d19a9 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 08:26:13 -0500
Subject: [PATCH 04/13] fix: address Copilot review round 3 findings
- Parse required_tools from issue form into requires.tools array in
extension catalog entries; preserve existing tools on updates
- Use full UTC timestamp (%H:%M:%SZ) instead of T00:00:00Z for
updated_at in both entry builders and catalog-pr.py
- Add catalog-table-start/end markers to README.md extension table
and update extension workflow to regenerate the table via
catalog-pr.py --table-target README.md
- Update extension table builder to include Category and Effect
columns matching the README format
- Remove unused RELEASE_PAT job-level env var from catalog-validate.yml
- Add contents:read permission to both validate jobs so
actions/checkout works with explicit permissions
- Add _SafeRedirectHandler to prevent SSRF via open redirect: validates
each redirect target against private/reserved IP checks before
following
---
.github/scripts/catalog-generate-table.py | 11 ++--
.github/scripts/catalog-pr.py | 2 +-
.github/scripts/catalog-validate.py | 78 +++++++++++++++++++++--
.github/workflows/catalog-pr.yml | 10 +--
.github/workflows/catalog-validate.yml | 4 +-
README.md | 2 +
6 files changed, 89 insertions(+), 18 deletions(-)
diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py
index fe7714d271..6e6a4cf06d 100644
--- a/.github/scripts/catalog-generate-table.py
+++ b/.github/scripts/catalog-generate-table.py
@@ -98,18 +98,21 @@ def build_extension_table(catalog: dict) -> str:
"""Build a markdown table for extensions."""
entries = catalog.get("extensions", {})
lines: list[str] = []
- lines.append("| Extension | Purpose | Provides | URL |")
- lines.append("|-----------|---------|----------|-----|")
+ lines.append("| Extension | Purpose | Category | Effect | URL |")
+ lines.append("|-----------|---------|----------|--------|-----|")
for _id in sorted(entries):
e = entries[_id]
name = e.get("name", _id)
desc = e.get("description", "")
- provides = _provides_str_extension(e.get("provides", {}))
+ category = e.get("category", "")
+ if category:
+ category = f"`{category}`"
+ effect = e.get("effect", "")
repo_url = e.get("repository", "")
repo_name = _repo_display_name(repo_url)
lines.append(
- f"| {name} | {desc} | {provides} "
+ f"| {name} | {desc} | {category} | {effect} "
f"| [{repo_name}]({repo_url}) |"
)
diff --git a/.github/scripts/catalog-pr.py b/.github/scripts/catalog-pr.py
index aa1ac46c01..bfa7832726 100644
--- a/.github/scripts/catalog-pr.py
+++ b/.github/scripts/catalog-pr.py
@@ -92,7 +92,7 @@ def main() -> None:
catalog = json.load(f)
catalog[cat_key][item_id] = new_entry
- catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+ catalog["updated_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
catalog[cat_key] = dict(sorted(catalog[cat_key].items()))
with open(catalog_path, "w") as f:
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 4a58aa2c6a..4bf043bd92 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -235,6 +235,35 @@ def validate_url(
return True, f"{field_name} URL format is valid."
+def _is_safe_redirect_target(url: str) -> bool:
+ """Return True if *url* does not point to a private/reserved address."""
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme not in ("http", "https"):
+ return False
+ hostname = parsed.hostname
+ if not hostname:
+ return False
+ try:
+ for _family, _type, _proto, _canonname, sockaddr in socket.getaddrinfo(hostname, None):
+ ip = ipaddress.ip_address(sockaddr[0])
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
+ return False
+ except (socket.gaierror, ValueError):
+ pass
+ return True
+
+
+class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
+ """Redirect handler that blocks redirects to private/reserved IPs."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ if not _is_safe_redirect_target(newurl):
+ raise urllib.error.URLError(
+ f"Redirect to private/reserved address blocked: {newurl}"
+ )
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
def check_url_reachable(
url: str, field_name: str, token: str | None = None,
) -> tuple[bool, str]:
@@ -263,12 +292,16 @@ def check_url_reachable(
_gh_hosts = {"github.com", "www.github.com", "codeload.github.com", "raw.githubusercontent.com"}
_is_github = hostname in _gh_hosts
+
+ # Build an opener that validates redirect targets against SSRF checks
+ opener = urllib.request.build_opener(_SafeRedirectHandler)
+
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "spec-kit-catalog-validator/1.0")
if token and _is_github:
req.add_header("Authorization", f"token {token}")
try:
- with urllib.request.urlopen(req, timeout=15) as resp:
+ with opener.open(req, timeout=15) as resp:
if resp.status < 400:
return True, f"{field_name} URL is reachable."
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
@@ -278,7 +311,7 @@ def check_url_reachable(
if token and _is_github:
req2.add_header("Authorization", f"token {token}")
try:
- with urllib.request.urlopen(req2, timeout=15) as resp2:
+ with opener.open(req2, timeout=15) as resp2:
if resp2.status < 400:
return True, f"{field_name} URL is reachable."
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc2:
@@ -551,7 +584,7 @@ def _build_extension_entry(
catalog: dict | None = None,
is_update: bool = False,
) -> dict:
- now = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
hooks = 0
if _present(fields.get("hooks_count", "")):
@@ -564,6 +597,39 @@ def _build_extension_entry(
if is_update and catalog:
existing = catalog.get("extensions", {}).get(item_id, {})
+ # Build requires dict — always include speckit_version, preserve/add tools
+ requires: dict = {
+ "speckit_version": fields["speckit_version"].strip(),
+ }
+ tools_raw = _clean(fields.get("required_tools", ""))
+ if tools_raw:
+ # Parse comma-separated "name>=version" entries
+ tools_list = []
+ for tool_str in tools_raw.split(","):
+ tool_str = tool_str.strip()
+ if not tool_str:
+ continue
+ # Try to split on version operator
+ for op in (">=", "<=", "==", "!=", ">", "<"):
+ if op in tool_str:
+ name, version = tool_str.split(op, 1)
+ tools_list.append({
+ "name": name.strip(),
+ "version": op + version.strip(),
+ "required": True,
+ })
+ break
+ else:
+ tools_list.append({
+ "name": tool_str,
+ "version": ">=0.0.0",
+ "required": True,
+ })
+ if tools_list:
+ requires["tools"] = tools_list
+ elif is_update and "tools" in existing.get("requires", {}):
+ requires["tools"] = existing["requires"]["tools"]
+
return {
"name": _clean(fields.get("item_name")),
"id": item_id,
@@ -582,9 +648,7 @@ def _build_extension_entry(
or repo + "/blob/main/CHANGELOG.md"
),
"license": fields["license"].strip(),
- "requires": {
- "speckit_version": fields["speckit_version"].strip(),
- },
+ "requires": requires,
"provides": {
"commands": int(fields["commands_count"].strip()),
"hooks": hooks,
@@ -603,7 +667,7 @@ def _build_preset_entry(
catalog: dict | None = None,
is_update: bool = False,
) -> dict:
- now = datetime.now(timezone.utc).strftime("%Y-%m-%dT00:00:00Z")
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
repo = _clean(fields.get("repository"))
item_id = _clean(fields.get("item_id"))
diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml
index 24e6c7d314..a06ce73a72 100644
--- a/.github/workflows/catalog-pr.yml
+++ b/.github/workflows/catalog-pr.yml
@@ -37,12 +37,13 @@ jobs:
--catalog extensions/catalog.community.json \
--type extension
- - name: Update catalog
+ - name: Update catalog and regenerate table
id: update
run: |
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
- --type extension
+ --type extension \
+ --table-target README.md
- name: Create or update pull request
if: steps.update.outputs.skipped != 'true'
@@ -68,7 +69,8 @@ jobs:
--type extension
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
- --type extension
+ --type extension \
+ --table-target README.md
else
git checkout -b "$BRANCH"
fi
@@ -77,7 +79,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add extensions/catalog.community.json
+ git add extensions/catalog.community.json README.md
git commit -m "${ACTION} community extension: ${ITEM_ID}
Automated from issue #${ISSUE_NUMBER}.
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index 129578c7e0..49cd3a64ce 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -13,9 +13,8 @@ jobs:
if: contains(github.event.issue.labels.*.name, 'extension-submission')
runs-on: ubuntu-latest
permissions:
+ contents: read
issues: write
- env:
- RELEASE_PAT: ${{ secrets.RELEASE_PAT }}
steps:
- uses: actions/checkout@v4
@@ -116,6 +115,7 @@ jobs:
if: contains(github.event.issue.labels.*.name, 'preset-submission')
runs-on: ubuntu-latest
permissions:
+ contents: read
issues: write
steps:
- uses: actions/checkout@v4
diff --git a/README.md b/README.md
index 419e7f919a..9784f4061d 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,7 @@ The following community-contributed extensions are available in [`catalog.commun
- `Read-only` — produces reports without modifying files
- `Read+Write` — modifies files, creates artifacts, or updates specs
+
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
@@ -274,6 +275,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
+
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
From 606eb9a02723df92f2f9190d9fe550e65334ad83 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 09:07:51 -0500
Subject: [PATCH 05/13] fix: address Copilot review round 4 findings
- Clarify semver vs v-prefix in publishing guides: note that the
catalog Version field should be '1.0.0' without the 'v' prefix
- Fix required_tools parser to handle markdown bullet list format
from the issue template ('- name (>=version) - required/optional')
with support for optional tools; keep comma-separated fallback
- Add is_unspecified and is_multicast to SSRF IP checks in both
check_url_reachable() and _is_safe_redirect_target()
- Preserve preset requires.extensions on updates so existing
extension dependencies aren't silently dropped
- Preserve existing preset documentation URL on updates instead of
always overwriting with repo/blob/main/README.md
- Use github.paginate() for bot comment search in both validation
jobs to handle issues with many comments
---
.github/scripts/catalog-validate.py | 83 +++++++++++++++++-------
.github/workflows/catalog-validate.yml | 30 +++++----
extensions/EXTENSION-PUBLISHING-GUIDE.md | 2 +-
presets/PUBLISHING.md | 2 +-
4 files changed, 79 insertions(+), 38 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 4bf043bd92..bfb77ad6ca 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -246,7 +246,7 @@ def _is_safe_redirect_target(url: str) -> bool:
try:
for _family, _type, _proto, _canonname, sockaddr in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(sockaddr[0])
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast:
return False
except (socket.gaierror, ValueError):
pass
@@ -283,7 +283,7 @@ def check_url_reachable(
addr_info = socket.getaddrinfo(hostname, None)
for _family, _type, _proto, _canonname, sockaddr in addr_info:
ip = ipaddress.ip_address(sockaddr[0])
- if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast:
return False, (
f"{field_name} URL `{url}` resolves to a private/reserved address."
)
@@ -603,28 +603,51 @@ def _build_extension_entry(
}
tools_raw = _clean(fields.get("required_tools", ""))
if tools_raw:
- # Parse comma-separated "name>=version" entries
+ # Parse markdown bullet list: "- name (>=version) - required/optional"
+ # Also handles comma-separated "name>=version" as fallback
tools_list = []
- for tool_str in tools_raw.split(","):
- tool_str = tool_str.strip()
- if not tool_str:
+ _tool_re = re.compile(
+ r"^[-*]\s+" # bullet
+ r"(?P[^\s(]+)" # tool name
+ r"(?:\s*\((?P[^)]+)\))?" # optional (>=x.y.z)
+ r"(?:\s*[-–—]\s*(?P\w+))?" # optional - required/optional
+ )
+ for line in tools_raw.splitlines():
+ line = line.strip()
+ if not line:
continue
- # Try to split on version operator
- for op in (">=", "<=", "==", "!=", ">", "<"):
- if op in tool_str:
- name, version = tool_str.split(op, 1)
- tools_list.append({
- "name": name.strip(),
- "version": op + version.strip(),
- "required": True,
- })
- break
- else:
+ m = _tool_re.match(line)
+ if m:
+ name = m.group("name").strip()
+ version = m.group("version") or ">=0.0.0"
+ version = version.strip()
+ req_str = (m.group("req") or "required").lower()
tools_list.append({
- "name": tool_str,
- "version": ">=0.0.0",
- "required": True,
+ "name": name,
+ "version": version,
+ "required": req_str != "optional",
})
+ else:
+ # Fallback: comma-separated "name>=version"
+ for part in line.split(","):
+ part = part.strip().lstrip("-*").strip()
+ if not part:
+ continue
+ for op in (">=", "<=", "==", "!=", ">", "<"):
+ if op in part:
+ n, v = part.split(op, 1)
+ tools_list.append({
+ "name": n.strip(),
+ "version": op + v.strip(),
+ "required": True,
+ })
+ break
+ else:
+ tools_list.append({
+ "name": part,
+ "version": ">=0.0.0",
+ "required": True,
+ })
if tools_list:
requires["tools"] = tools_list
elif is_update and "tools" in existing.get("requires", {}):
@@ -679,6 +702,20 @@ def _build_preset_entry(
if is_update and catalog:
existing = catalog.get("presets", {}).get(item_id, {})
+ # Build requires — preserve existing extensions on updates
+ requires: dict = {
+ "speckit_version": fields["speckit_version"].strip(),
+ }
+ if is_update and "extensions" in existing.get("requires", {}):
+ requires["extensions"] = existing["requires"]["extensions"]
+
+ # Documentation URL: use existing on update, fall back to repo/blob/main/README.md
+ documentation = _clean(fields.get("documentation", ""))
+ if not documentation and is_update:
+ documentation = existing.get("documentation", "")
+ if not documentation:
+ documentation = repo + "/blob/main/README.md"
+
return {
"name": _clean(fields.get("item_name")),
"id": item_id,
@@ -688,11 +725,9 @@ def _build_preset_entry(
"repository": repo,
"download_url": _clean(fields.get("download_url")),
"homepage": repo,
- "documentation": repo + "/blob/main/README.md",
+ "documentation": documentation,
"license": fields["license"].strip(),
- "requires": {
- "speckit_version": fields["speckit_version"].strip(),
- },
+ "requires": requires,
"provides": {
"templates": templates_count,
"commands": commands_count,
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index 49cd3a64ce..2df7a722fd 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -43,13 +43,16 @@ jobs:
const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
// Find existing bot comment to update (avoid spam)
- const comments = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ }
+ );
const marker = '';
- const botComment = comments.data.find(c =>
+ const botComment = allComments.find(c =>
c.user.type === 'Bot' && c.body.includes(marker)
);
@@ -144,13 +147,16 @@ jobs:
const report = fs.readFileSync('/tmp/validation-report.md', 'utf8');
const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
- const comments = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
+ const allComments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ }
+ );
const marker = '';
- const botComment = comments.data.find(c =>
+ const botComment = allComments.find(c =>
c.user.type === 'Bot' && c.body.includes(marker)
);
diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md
index b26e617935..8886617257 100644
--- a/extensions/EXTENSION-PUBLISHING-GUIDE.md
+++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md
@@ -18,7 +18,7 @@ For how to develop and test an extension, see the [Development Guide](EXTENSION-
Before publishing, ensure you have:
1. **A working extension** — developed and tested per the [Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)
-2. **A GitHub release** — tagged with a semver version (e.g., `v1.0.0`)
+2. **A GitHub release** — with a release tag for the version you are publishing (the tag is commonly `v1.0.0`; the catalog Version field should be `1.0.0` without the `v` prefix)
3. **A valid download URL** — the release archive URL for your tag
4. **An open-source license** — MIT, Apache 2.0, etc.
diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md
index ef670a0619..7bbcfd17c4 100644
--- a/presets/PUBLISHING.md
+++ b/presets/PUBLISHING.md
@@ -18,7 +18,7 @@ For how to develop and test a preset, see the [Development Guide](DEVELOPING.md)
Before publishing, ensure you have:
1. **A working preset** — developed and tested per the [Development Guide](DEVELOPING.md)
-2. **A GitHub release** — tagged with a semver version (e.g., `v1.0.0`)
+2. **A GitHub release** — with a release tag for the version you are publishing (the tag is commonly `v1.0.0`; the catalog Version field should be `1.0.0` without the `v` prefix)
3. **A valid download URL** — the release archive URL for your tag
4. **An open-source license** — MIT, Apache 2.0, etc.
From f6b53413d82c7954a2e65c2d8ab080552e9fe4a7 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 09:21:18 -0500
Subject: [PATCH 06/13] fix: address Copilot review round 5 findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add 'Required Extensions' and 'Number of Scripts' fields to preset
issue template and wire through label mapping, validation, and builder
so new submissions can express requires.extensions and provides.scripts
- Make 'Templates Provided' optional in preset validation — require at
least one of templates or commands (supports command-only presets)
- Fix bot comment matching: use marker-only search instead of
c.user.type === 'Bot' since RELEASE_PAT creates comments as a User
- Preserve provides.scripts on preset updates
---
.github/ISSUE_TEMPLATE/preset_submission.yml | 18 +++++--
.github/scripts/catalog-validate.py | 49 ++++++++++++++++----
.github/workflows/catalog-validate.yml | 4 +-
3 files changed, 56 insertions(+), 15 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml
index 3a1b963492..4d6b25bb15 100644
--- a/.github/ISSUE_TEMPLATE/preset_submission.yml
+++ b/.github/ISSUE_TEMPLATE/preset_submission.yml
@@ -95,17 +95,22 @@ body:
validations:
required: true
+ - type: input
+ id: required-extensions
+ attributes:
+ label: Required Extensions (optional)
+ description: Comma-separated list of required extension IDs (e.g., aide)
+ placeholder: "e.g., aide, canon"
+
- type: textarea
id: templates-provided
attributes:
label: Templates Provided
- description: List the template overrides your preset provides
+ description: List the template overrides your preset provides (leave empty for command-only presets)
placeholder: |
- spec-template.md — adds compliance section
- plan-template.md — includes audit checkpoints
- checklist-template.md — HIPAA compliance checklist
- validations:
- required: true
- type: textarea
id: commands-provided
@@ -115,6 +120,13 @@ body:
placeholder: |
- speckit.specify.md — customized for compliance workflows
+ - type: input
+ id: scripts-count
+ attributes:
+ label: Number of Scripts (optional)
+ description: How many scripts does your preset provide? (leave empty if none)
+ placeholder: "e.g., 1"
+
- type: textarea
id: tags
attributes:
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index bfb77ad6ca..92b261ae62 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -100,8 +100,10 @@ def parse_issue_body(body: str) -> dict[str, str]:
"Download URL": "download_url",
"License": "license",
"Required Spec Kit Version": "speckit_version",
+ "Required Extensions (optional)": "required_extensions",
"Templates Provided": "templates_provided",
"Commands Provided (optional)": "commands_provided",
+ "Number of Scripts (optional)": "scripts_count",
"Tags": "tags",
"Key Features": "features",
"Testing Checklist": "testing_checklist",
@@ -503,10 +505,13 @@ def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None:
ok, msg = validate_hooks_count(fields.get("hooks_count", ""))
_add("Number of Hooks", ok, msg)
elif catalog_type == "preset":
- ok, msg = validate_required_text(
- fields.get("templates_provided", ""), "Templates Provided",
- )
- _add("Templates Provided", ok, msg)
+ templates_provided = fields.get("templates_provided", "").strip()
+ commands_provided = fields.get("commands_provided", "").strip()
+ if templates_provided or commands_provided:
+ _add("Preset Provides", True, "Templates and/or commands provided.")
+ else:
+ _add("Preset Provides", False,
+ "At least one of Templates Provided or Commands Provided is required.")
# Commands Provided is optional for presets
# --- Tags ---
@@ -702,11 +707,23 @@ def _build_preset_entry(
if is_update and catalog:
existing = catalog.get("presets", {}).get(item_id, {})
- # Build requires — preserve existing extensions on updates
+ # Build requires — include extensions from form or preserve on updates
requires: dict = {
"speckit_version": fields["speckit_version"].strip(),
}
- if is_update and "extensions" in existing.get("requires", {}):
+ extensions_raw = _clean(fields.get("required_extensions", ""))
+ if extensions_raw:
+ # Parse comma-separated or bullet-list extension IDs
+ ext_list = []
+ for line in extensions_raw.splitlines():
+ line = line.strip().lstrip("-*").strip()
+ for part in line.split(","):
+ part = part.strip()
+ if part:
+ ext_list.append(part)
+ if ext_list:
+ requires["extensions"] = ext_list
+ elif is_update and "extensions" in existing.get("requires", {}):
requires["extensions"] = existing["requires"]["extensions"]
# Documentation URL: use existing on update, fall back to repo/blob/main/README.md
@@ -716,6 +733,21 @@ def _build_preset_entry(
if not documentation:
documentation = repo + "/blob/main/README.md"
+ # Scripts count from form or preserve on updates
+ scripts_count = 0
+ scripts_raw = _clean(fields.get("scripts_count", ""))
+ if scripts_raw and scripts_raw.isdigit():
+ scripts_count = int(scripts_raw)
+ elif is_update:
+ scripts_count = existing.get("provides", {}).get("scripts", 0)
+
+ provides: dict = {
+ "templates": templates_count,
+ "commands": commands_count,
+ }
+ if scripts_count:
+ provides["scripts"] = scripts_count
+
return {
"name": _clean(fields.get("item_name")),
"id": item_id,
@@ -728,10 +760,7 @@ def _build_preset_entry(
"documentation": documentation,
"license": fields["license"].strip(),
"requires": requires,
- "provides": {
- "templates": templates_count,
- "commands": commands_count,
- },
+ "provides": provides,
"tags": parse_tags(fields["tags"]),
"created_at": existing.get("created_at", now),
"updated_at": now,
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index 2df7a722fd..f44c346531 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -53,7 +53,7 @@ jobs:
);
const marker = '';
const botComment = allComments.find(c =>
- c.user.type === 'Bot' && c.body.includes(marker)
+ c.body && c.body.includes(marker)
);
const body = marker + '\n\n' + report;
@@ -157,7 +157,7 @@ jobs:
);
const marker = '';
const botComment = allComments.find(c =>
- c.user.type === 'Bot' && c.body.includes(marker)
+ c.body && c.body.includes(marker)
);
const body = marker + '\n\n' + report;
From c9ddb131c4fe64b690720a47aedc019891db3c9f Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 09:39:23 -0500
Subject: [PATCH 07/13] fix: address Copilot review round 6 findings
- Remove hardcoded --assignee mnriem from gh pr create; rely on
CODEOWNERS for review routing
- Remove --table-target README.md from extension workflow since the
catalog JSON lacks category/effect fields needed by the README table
- Relax 200-char description limit for updates (warn instead of block)
so existing long-description entries can be updated
- Validate speckit_version with packaging.specifiers.SpecifierSet for
full PEP 440 compliance; fall back to regex if packaging unavailable
- Split PAT usage in catalog-validate.yml: use default GITHUB_TOKEN
for comment read/write, RELEASE_PAT only for label mutation step
---
.github/scripts/catalog-validate.py | 23 +++++++++++++++++++----
.github/workflows/catalog-pr.yml | 16 ++++++----------
.github/workflows/catalog-validate.yml | 24 ++++++++++++++++++------
3 files changed, 43 insertions(+), 20 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 92b261ae62..8aabca92e2 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -213,11 +213,16 @@ def validate_version(
return True, f"Version `{value}` is valid."
-def validate_description(value: str) -> tuple[bool, str]:
+def validate_description(value: str, *, is_update: bool = False) -> tuple[bool, str]:
if not _present(value):
return False, "Description is required."
value = value.strip()
if len(value) > 200:
+ if is_update:
+ return True, (
+ f"Description is {len(value)} characters (over 200). "
+ "Consider shortening it in a future update."
+ )
return False, (
f"Description is {len(value)} characters — please keep it under 200."
)
@@ -327,11 +332,21 @@ def validate_speckit_version(value: str) -> tuple[bool, str]:
if not _present(value):
return False, "Required Spec Kit Version is required."
value = value.strip()
- if not _VERSION_CONSTRAINT_RE.match(value):
+ try:
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
+ SpecifierSet(value)
+ except InvalidSpecifier as exc:
return False, (
- f"Spec Kit version constraint `{value}` looks invalid. "
+ f"Spec Kit version constraint `{value}` is invalid: {exc}. "
"Use a PEP 440 constraint like `>=0.6.0`."
)
+ except ImportError:
+ # Fallback if packaging is not available
+ if not _VERSION_CONSTRAINT_RE.match(value):
+ return False, (
+ f"Spec Kit version constraint `{value}` looks invalid. "
+ "Use a PEP 440 constraint like `>=0.6.0`."
+ )
return True, f"Version constraint `{value}` is valid."
@@ -453,7 +468,7 @@ def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None:
_add("Version", ok, msg)
# --- Common fields ---
- ok, msg = validate_description(fields.get("description", ""))
+ ok, msg = validate_description(fields.get("description", ""), is_update=is_update)
_add("Description", ok, msg)
ok, msg = validate_required_text(fields.get("author", ""), "Author")
diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml
index a06ce73a72..0f44c74385 100644
--- a/.github/workflows/catalog-pr.yml
+++ b/.github/workflows/catalog-pr.yml
@@ -37,13 +37,12 @@ jobs:
--catalog extensions/catalog.community.json \
--type extension
- - name: Update catalog and regenerate table
+ - name: Update catalog
id: update
run: |
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
- --type extension \
- --table-target README.md
+ --type extension
- name: Create or update pull request
if: steps.update.outputs.skipped != 'true'
@@ -69,8 +68,7 @@ jobs:
--type extension
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
- --type extension \
- --table-target README.md
+ --type extension
else
git checkout -b "$BRANCH"
fi
@@ -79,7 +77,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add extensions/catalog.community.json README.md
+ git add extensions/catalog.community.json
git commit -m "${ACTION} community extension: ${ITEM_ID}
Automated from issue #${ISSUE_NUMBER}.
@@ -121,8 +119,7 @@ jobs:
--title "${ACTION} community extension: ${ITEM_ID}" \
--body "$PR_BODY" \
--head "$BRANCH" \
- --base main \
- --assignee mnriem
+ --base main
NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
gh issue comment "$ISSUE_NUMBER" \
@@ -241,8 +238,7 @@ jobs:
--title "${ACTION} community preset: ${ITEM_ID}" \
--body "$PR_BODY" \
--head "$BRANCH" \
- --base main \
- --assignee mnriem
+ --base main
NEW_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
gh issue comment "$ISSUE_NUMBER" \
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index f44c346531..b802361316 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -33,14 +33,12 @@ jobs:
--catalog extensions/catalog.community.json \
--type extension
- - name: Post validation comment and update labels
+ - name: Post validation comment
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.RELEASE_PAT }}
script: |
const fs = require('fs');
const report = fs.readFileSync('/tmp/validation-report.md', 'utf8');
- const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
// Find existing bot comment to update (avoid spam)
const allComments = await github.paginate(
@@ -74,6 +72,14 @@ jobs:
});
}
+ - name: Update labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.RELEASE_PAT }}
+ script: |
+ const fs = require('fs');
+ const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
+
// Update labels — uses RELEASE_PAT so the label event
// triggers the catalog-pr workflow
const currentLabels = context.payload.issue.labels.map(l => l.name);
@@ -138,14 +144,12 @@ jobs:
--catalog presets/catalog.community.json \
--type preset
- - name: Post validation comment and update labels
+ - name: Post validation comment
uses: actions/github-script@v7
with:
- github-token: ${{ secrets.RELEASE_PAT }}
script: |
const fs = require('fs');
const report = fs.readFileSync('/tmp/validation-report.md', 'utf8');
- const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
const allComments = await github.paginate(
github.rest.issues.listComments,
@@ -178,6 +182,14 @@ jobs:
});
}
+ - name: Update labels
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.RELEASE_PAT }}
+ script: |
+ const fs = require('fs');
+ const result = JSON.parse(fs.readFileSync('/tmp/validation-result.json', 'utf8'));
+
const currentLabels = context.payload.issue.labels.map(l => l.name);
if (result.valid) {
From 8278474caf584198b32e147bf6321e9dacdfe2b5 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 10:09:10 -0500
Subject: [PATCH 08/13] fix: fix SSRF comment and remove redundant validate
re-runs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Update SSRF guard comment to say 'non-HTTP(S) schemes' matching the
actual code that allows both http and https
- Remove catalog-validate.py re-runs in branch-exists paths of
catalog-pr.yml — the /tmp artifacts from the prior step are already
available, and re-running without ISSUE_BODY env var would fail
---
.github/scripts/catalog-validate.py | 2 +-
.github/workflows/catalog-pr.yml | 6 ------
2 files changed, 1 insertion(+), 7 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 8aabca92e2..e114886fd1 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -279,7 +279,7 @@ def check_url_reachable(
return True, "" # skip if empty/optional
url = url.strip()
- # --- SSRF guard: reject non-HTTPS, private/loopback IPs ---
+ # --- SSRF guard: reject non-HTTP(S) schemes, private/loopback IPs ---
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
return False, f"{field_name} URL must use http or https scheme."
diff --git a/.github/workflows/catalog-pr.yml b/.github/workflows/catalog-pr.yml
index 0f44c74385..26b7afc446 100644
--- a/.github/workflows/catalog-pr.yml
+++ b/.github/workflows/catalog-pr.yml
@@ -63,9 +63,6 @@ jobs:
git checkout "$BRANCH"
git reset --hard origin/main
# Re-run the catalog update on the fresh branch
- python .github/scripts/catalog-validate.py \
- --catalog extensions/catalog.community.json \
- --type extension
python .github/scripts/catalog-pr.py \
--catalog extensions/catalog.community.json \
--type extension
@@ -181,9 +178,6 @@ jobs:
git checkout "$BRANCH"
git reset --hard origin/main
# Re-run on the fresh branch
- python .github/scripts/catalog-validate.py \
- --catalog presets/catalog.community.json \
- --type preset
python .github/scripts/catalog-pr.py \
--catalog presets/catalog.community.json \
--type preset \
From 1c10491a424970ffc84d6131c5d99e827425ff49 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 11:17:25 -0500
Subject: [PATCH 09/13] fix: address Copilot review round 8 findings
- Raise tag limit from 5 to 10 to match existing catalog entries;
update publishing guides accordingly
- Deduplicate tags in parse_tags() so duplicate submissions produce
stable catalog output
- Make _count_list_items() tolerant of non-bullet formats: count all
non-empty lines when no bullets are present
- Add 29 unit tests for catalog-validate.py covering parse_issue_body,
tags, description, speckit_version, _count_list_items, and SSRF guard
---
.github/scripts/catalog-validate.py | 23 +--
extensions/EXTENSION-PUBLISHING-GUIDE.md | 2 +-
presets/PUBLISHING.md | 2 +-
tests/test_catalog_validate.py | 183 +++++++++++++++++++++++
4 files changed, 198 insertions(+), 12 deletions(-)
create mode 100644 tests/test_catalog_validate.py
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index e114886fd1..89cd24343a 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -376,8 +376,8 @@ def validate_tags(value: str) -> tuple[bool, str]:
raw_tags = [t.strip().lower() for t in value.split(",") if t.strip()]
if len(raw_tags) < 2:
return False, "Please provide at least 2 tags."
- if len(raw_tags) > 5:
- return False, f"Too many tags ({len(raw_tags)}). Please provide 2-5 tags."
+ if len(raw_tags) > 10:
+ return False, f"Too many tags ({len(raw_tags)}). Please provide 2-10 tags."
bad = [t for t in raw_tags if not re.match(r"^[a-z0-9-]+$", t)]
if bad:
return False, (
@@ -413,13 +413,16 @@ def validate_checklist(value: str, field_name: str) -> tuple[bool, str]:
def _count_list_items(value: str) -> int:
- """Count markdown list items (``- item``) in a textarea value."""
+ """Count items in a textarea value (bullets or non-empty lines)."""
if not _present(value):
return 0
- return sum(
- 1 for line in value.splitlines()
- if line.strip().startswith("- ") or line.strip().startswith("* ")
- )
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
+ # If any lines use bullet format, count only those
+ bullets = [l for l in lines if l.startswith(("- ", "* "))]
+ if bullets:
+ return len(bullets)
+ # Otherwise count all non-empty lines
+ return len(lines)
# ---------------------------------------------------------------------------
@@ -568,12 +571,12 @@ def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None:
# ---------------------------------------------------------------------------
def parse_tags(value: str) -> list[str]:
- """Parse comma-separated tags into a sorted list."""
- return sorted(
+ """Parse comma-separated tags into a sorted, deduplicated list."""
+ return sorted(set(
t.strip().lower()
for t in value.split(",")
if t.strip()
- )
+ ))
def _clean(value: str | None) -> str:
diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md
index 8886617257..3ff4e281d6 100644
--- a/extensions/EXTENSION-PUBLISHING-GUIDE.md
+++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md
@@ -49,7 +49,7 @@ Submit your extension by opening an issue using the **Extension Submission** tem
- Description length (under 200 characters)
- Required URLs are present and reachable
- Spec Kit version constraint is valid
- - Tags format and count (2-5 lowercase tags)
+ - Tags format and count (2-10 lowercase tags)
- All required fields and checklists are completed
2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically.
3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that:
diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md
index 7bbcfd17c4..0e3bc3989e 100644
--- a/presets/PUBLISHING.md
+++ b/presets/PUBLISHING.md
@@ -53,7 +53,7 @@ Submit your preset by opening an issue using the **Preset Submission** template:
- Description length (under 200 characters)
- Required URLs are present and reachable
- Spec Kit version constraint is valid
- - Tags format and count (2-5 lowercase tags)
+ - Tags format and count (2-10 lowercase tags)
- All required fields and checklists are completed
2. If any checks fail, the issue gets a `needs-changes` label with details on what to fix. Edit the issue to correct the fields and validation re-runs automatically.
3. Once all checks pass, the issue gets a `validated` label and a **pull request is created automatically** that:
diff --git a/tests/test_catalog_validate.py b/tests/test_catalog_validate.py
new file mode 100644
index 0000000000..f70529bb0c
--- /dev/null
+++ b/tests/test_catalog_validate.py
@@ -0,0 +1,183 @@
+"""Tests for .github/scripts/catalog-validate.py."""
+
+from __future__ import annotations
+
+import importlib
+import ipaddress
+import sys
+import types
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Import the script as a module
+# ---------------------------------------------------------------------------
+
+SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "catalog-validate.py"
+
+
+@pytest.fixture(scope="module")
+def cv():
+ """Import catalog-validate.py as a module."""
+ spec = importlib.util.spec_from_file_location("catalog_validate", SCRIPT)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+# ---------------------------------------------------------------------------
+# parse_issue_body
+# ---------------------------------------------------------------------------
+
+
+class TestParseIssueBody:
+ def test_basic_fields(self, cv):
+ body = "### Name\nAlice\n### Version\n1.0.0\n"
+ result = cv.parse_issue_body(body)
+ assert result["Name"] == "Alice"
+ assert result["Version"] == "1.0.0"
+
+ def test_multiline_field(self, cv):
+ body = "### Description\nLine one\nLine two\n### End\nval"
+ result = cv.parse_issue_body(body)
+ assert "Line one\nLine two" == result["Description"]
+
+ def test_empty_body(self, cv):
+ assert cv.parse_issue_body("") == {}
+
+ def test_checkbox_field(self, cv):
+ body = "### Checklist\n- [X] Item 1\n- [ ] Item 2\n"
+ result = cv.parse_issue_body(body)
+ assert "- [X] Item 1" in result["Checklist"]
+
+
+# ---------------------------------------------------------------------------
+# parse_tags / validate_tags
+# ---------------------------------------------------------------------------
+
+
+class TestTags:
+ def test_parse_tags_dedup(self, cv):
+ assert cv.parse_tags("foo, foo, bar") == ["bar", "foo"]
+
+ def test_parse_tags_sorted(self, cv):
+ assert cv.parse_tags("z, a, m") == ["a", "m", "z"]
+
+ def test_validate_tags_too_few(self, cv):
+ ok, _ = cv.validate_tags("single")
+ assert not ok
+
+ def test_validate_tags_too_many(self, cv):
+ ok, _ = cv.validate_tags(", ".join(f"tag{i}" for i in range(11)))
+ assert not ok
+
+ def test_validate_tags_max_10(self, cv):
+ ok, _ = cv.validate_tags(", ".join(f"tag{i}" for i in range(10)))
+ assert ok
+
+ def test_validate_tags_bad_chars(self, cv):
+ ok, _ = cv.validate_tags("good, BAD CHARS!")
+ assert not ok
+
+
+# ---------------------------------------------------------------------------
+# validate_description
+# ---------------------------------------------------------------------------
+
+
+class TestValidateDescription:
+ def test_empty(self, cv):
+ ok, _ = cv.validate_description("")
+ assert not ok
+
+ def test_valid(self, cv):
+ ok, _ = cv.validate_description("Short description")
+ assert ok
+
+ def test_over_limit_new(self, cv):
+ ok, _ = cv.validate_description("x" * 201)
+ assert not ok
+
+ def test_over_limit_update_warns(self, cv):
+ ok, msg = cv.validate_description("x" * 201, is_update=True)
+ assert ok
+ assert "Consider shortening" in msg
+
+
+# ---------------------------------------------------------------------------
+# validate_speckit_version
+# ---------------------------------------------------------------------------
+
+
+class TestValidateSpeckitVersion:
+ def test_valid(self, cv):
+ ok, _ = cv.validate_speckit_version(">=0.6.0")
+ assert ok
+
+ def test_valid_multi(self, cv):
+ ok, _ = cv.validate_speckit_version(">=0.6.0,<1.0.0")
+ assert ok
+
+ def test_invalid(self, cv):
+ ok, _ = cv.validate_speckit_version("not-a-version")
+ assert not ok
+
+ def test_empty(self, cv):
+ ok, _ = cv.validate_speckit_version("")
+ assert not ok
+
+
+# ---------------------------------------------------------------------------
+# _count_list_items
+# ---------------------------------------------------------------------------
+
+
+class TestCountListItems:
+ def test_bullets(self, cv):
+ assert cv._count_list_items("- one\n- two\n- three") == 3
+
+ def test_asterisks(self, cv):
+ assert cv._count_list_items("* one\n* two") == 2
+
+ def test_plain_lines(self, cv):
+ assert cv._count_list_items("one\ntwo\nthree") == 3
+
+ def test_empty(self, cv):
+ assert cv._count_list_items("") == 0
+
+ def test_mixed_blank(self, cv):
+ assert cv._count_list_items("- one\n\n- two") == 2
+
+
+# ---------------------------------------------------------------------------
+# SSRF guard (_is_safe_redirect_target)
+# ---------------------------------------------------------------------------
+
+
+class TestSSRFGuard:
+ def test_rejects_private_ip(self, cv):
+ assert not cv._is_safe_redirect_target("http://127.0.0.1/evil")
+
+ def test_rejects_non_http(self, cv):
+ assert not cv._is_safe_redirect_target("ftp://example.com/file")
+
+ def test_rejects_no_hostname(self, cv):
+ assert not cv._is_safe_redirect_target("http:///path")
+
+ def test_allows_public(self, cv):
+ # Mock DNS to return a public IP
+ fake_addr = [(None, None, None, None, ("93.184.216.34", 0))]
+ with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr):
+ assert cv._is_safe_redirect_target("https://example.com")
+
+ def test_rejects_multicast(self, cv):
+ fake_addr = [(None, None, None, None, ("224.0.0.1", 0))]
+ with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr):
+ assert not cv._is_safe_redirect_target("https://multicast.test")
+
+ def test_rejects_unspecified(self, cv):
+ fake_addr = [(None, None, None, None, ("0.0.0.0", 0))]
+ with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr):
+ assert not cv._is_safe_redirect_target("https://zero.test")
From b65d14a5e9af9b17d997c3adcc6c5e9115c3b41a Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:34:31 -0500
Subject: [PATCH 10/13] fix: address Copilot review round 9 findings
- Deduplicate tags in validate_tags() before counting; surface a
message when duplicates are removed
- Require at least one checkbox item in validate_checklist() so
missing/mangled checkbox syntax fails instead of silently passing
- Use packaging.version.Version for semver comparison with fallback,
fixing incorrect pre-release handling (e.g. 1.0.0-alpha vs 1.0.0)
- Omit version key from tools when no version is supplied instead of
writing a synthetic >=0.0.0 constraint
- Fail closed in _is_safe_redirect_target() on DNS resolution failure
to prevent DNS rebinding bypass
- Re-add 'validated' label on issue edits (remove + add) so
catalog-pr.yml is retriggered to update the generated PR
- Add tests for tag dedup validation and DNS-fail-closed behavior
---
.github/scripts/catalog-validate.py | 38 ++++++++++++++++++--------
.github/workflows/catalog-validate.yml | 26 ++++++++++++++----
tests/test_catalog_validate.py | 17 ++++++++++++
3 files changed, 63 insertions(+), 18 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 89cd24343a..627ed495b6 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -157,7 +157,12 @@ def _present(value: str | None) -> bool:
def _parse_semver(version: str) -> tuple[int, ...]:
"""Parse a semver string into a comparable tuple of ints."""
- # Strip pre-release/build metadata for comparison
+ try:
+ from packaging.version import Version
+ return Version(version)
+ except (ImportError, Exception):
+ pass
+ # Fallback: strip pre-release/build metadata for comparison
base = version.split("-")[0].split("+")[0]
return tuple(int(p) for p in base.split("."))
@@ -256,7 +261,7 @@ def _is_safe_redirect_target(url: str) -> bool:
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified or ip.is_multicast:
return False
except (socket.gaierror, ValueError):
- pass
+ return False # fail closed: unresolvable targets are blocked
return True
@@ -373,17 +378,23 @@ def validate_hooks_count(value: str) -> tuple[bool, str]:
def validate_tags(value: str) -> tuple[bool, str]:
if not _present(value):
return False, "Tags are required."
- raw_tags = [t.strip().lower() for t in value.split(",") if t.strip()]
+ raw_tags = list(dict.fromkeys(
+ t.strip().lower() for t in value.split(",") if t.strip()
+ )) # dedupe preserving order
+ dupes_removed = len([t.strip().lower() for t in value.split(",") if t.strip()]) - len(raw_tags)
if len(raw_tags) < 2:
- return False, "Please provide at least 2 tags."
+ return False, "Please provide at least 2 unique tags."
if len(raw_tags) > 10:
- return False, f"Too many tags ({len(raw_tags)}). Please provide 2-10 tags."
+ return False, f"Too many tags ({len(raw_tags)} unique). Please provide 2-10 tags."
bad = [t for t in raw_tags if not re.match(r"^[a-z0-9-]+$", t)]
if bad:
return False, (
f"Tags must be lowercase alphanumeric with hyphens: {', '.join(bad)}"
)
- return True, f"Tags: {', '.join(raw_tags)}."
+ msg = f"Tags: {', '.join(raw_tags)}."
+ if dupes_removed:
+ msg += f" ({dupes_removed} duplicate(s) removed.)"
+ return True, msg
def validate_license(value: str) -> tuple[bool, str]:
@@ -403,6 +414,8 @@ def validate_checklist(value: str, field_name: str) -> tuple[bool, str]:
if not _present(value):
return False, f"{field_name} is required."
lines = [l.strip() for l in value.splitlines() if l.strip().startswith("- [")]
+ if not lines:
+ return False, f"{field_name} must contain at least one checkbox item."
unchecked = [l for l in lines if l.startswith("- [ ]")]
if unchecked:
items = "\n".join(f" - {l[5:].strip()}" for l in unchecked)
@@ -642,14 +655,16 @@ def _build_extension_entry(
m = _tool_re.match(line)
if m:
name = m.group("name").strip()
- version = m.group("version") or ">=0.0.0"
- version = version.strip()
+ version = m.group("version")
+ version = version.strip() if version else None
req_str = (m.group("req") or "required").lower()
- tools_list.append({
+ tool_entry: dict = {
"name": name,
- "version": version,
"required": req_str != "optional",
- })
+ }
+ if version:
+ tool_entry["version"] = version
+ tools_list.append(tool_entry)
else:
# Fallback: comma-separated "name>=version"
for part in line.split(","):
@@ -668,7 +683,6 @@ def _build_extension_entry(
else:
tools_list.append({
"name": part,
- "version": ">=0.0.0",
"required": True,
})
if tools_list:
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index b802361316..7f0cfbd467 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -93,14 +93,21 @@ jobs:
name: 'needs-changes',
});
}
- if (!currentLabels.includes('validated')) {
- await github.rest.issues.addLabels({
+ if (currentLabels.includes('validated')) {
+ // Remove + re-add to retrigger catalog-pr on edits
+ await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
- labels: ['validated'],
+ name: 'validated',
});
}
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['validated'],
+ });
} else {
if (currentLabels.includes('validated')) {
await github.rest.issues.removeLabel({
@@ -201,14 +208,21 @@ jobs:
name: 'needs-changes',
});
}
- if (!currentLabels.includes('validated')) {
- await github.rest.issues.addLabels({
+ if (currentLabels.includes('validated')) {
+ // Remove + re-add to retrigger catalog-pr on edits
+ await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
- labels: ['validated'],
+ name: 'validated',
});
}
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ labels: ['validated'],
+ });
} else {
if (currentLabels.includes('validated')) {
await github.rest.issues.removeLabel({
diff --git a/tests/test_catalog_validate.py b/tests/test_catalog_validate.py
index f70529bb0c..61bc4b62a7 100644
--- a/tests/test_catalog_validate.py
+++ b/tests/test_catalog_validate.py
@@ -81,6 +81,17 @@ def test_validate_tags_bad_chars(self, cv):
ok, _ = cv.validate_tags("good, BAD CHARS!")
assert not ok
+ def test_validate_tags_dedup_count(self, cv):
+ # 3 raw tags but only 2 unique — should still pass (>= 2)
+ ok, msg = cv.validate_tags("foo, foo, bar")
+ assert ok
+ assert "duplicate" in msg.lower()
+
+ def test_validate_tags_dedup_too_few(self, cv):
+ # All dupes of same tag — only 1 unique
+ ok, _ = cv.validate_tags("foo, foo, foo")
+ assert not ok
+
# ---------------------------------------------------------------------------
# validate_description
@@ -181,3 +192,9 @@ def test_rejects_unspecified(self, cv):
fake_addr = [(None, None, None, None, ("0.0.0.0", 0))]
with patch.object(cv.socket, "getaddrinfo", return_value=fake_addr):
assert not cv._is_safe_redirect_target("https://zero.test")
+
+ def test_rejects_unresolvable(self, cv):
+ """DNS failure should fail closed (block, not allow)."""
+ import socket as _socket
+ with patch.object(cv.socket, "getaddrinfo", side_effect=_socket.gaierror):
+ assert not cv._is_safe_redirect_target("https://unresolvable.test")
From d627a646abfcbee2825da444490ddbc0129b10fd Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 17:26:18 -0500
Subject: [PATCH 11/13] fix: address Copilot review round 10 findings
- Fix re.sub in update_file() to use a lambda replacement function
instead of a replacement string, preventing backslash/group-reference
corruption in generated table content
- Add _escape_cell() helper to escape pipe characters and collapse
newlines in markdown table cells from user-submitted data
- Remove unused imports (ipaddress, sys, types) from test module
- Add scripts_count validation for preset submissions (non-negative
integer when provided)
- Remove stale catalog-table-start/end markers from README.md since
the extension workflow does not regenerate this table (catalog JSON
lacks category/effect fields)
---
.github/scripts/catalog-generate-table.py | 19 ++++++++++++-------
.github/scripts/catalog-validate.py | 8 ++++++++
README.md | 2 --
tests/test_catalog_validate.py | 3 ---
4 files changed, 20 insertions(+), 12 deletions(-)
diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py
index 6e6a4cf06d..0a53485df2 100644
--- a/.github/scripts/catalog-generate-table.py
+++ b/.github/scripts/catalog-generate-table.py
@@ -60,6 +60,11 @@ def _requires_str_preset(requires: dict) -> str:
return "—"
+def _escape_cell(value: str) -> str:
+ """Escape pipe characters and collapse whitespace for markdown table cells."""
+ return value.replace("|", "\\|").replace("\n", " ").replace("\r", "").strip()
+
+
def build_preset_table(catalog: dict) -> str:
"""Build a markdown table for presets."""
entries = catalog.get("presets", {})
@@ -69,8 +74,8 @@ def build_preset_table(catalog: dict) -> str:
for _id in sorted(entries):
e = entries[_id]
- name = e.get("name", _id)
- desc = e.get("description", "")
+ name = _escape_cell(e.get("name", _id))
+ desc = _escape_cell(e.get("description", ""))
provides = _provides_str_preset(e.get("provides", {}))
requires = _requires_str_preset(e.get("requires", {}))
repo_url = e.get("repository", "")
@@ -103,12 +108,12 @@ def build_extension_table(catalog: dict) -> str:
for _id in sorted(entries):
e = entries[_id]
- name = e.get("name", _id)
- desc = e.get("description", "")
- category = e.get("category", "")
+ name = _escape_cell(e.get("name", _id))
+ desc = _escape_cell(e.get("description", ""))
+ category = _escape_cell(e.get("category", ""))
if category:
category = f"`{category}`"
- effect = e.get("effect", "")
+ effect = _escape_cell(e.get("effect", ""))
repo_url = e.get("repository", "")
repo_name = _repo_display_name(repo_url)
lines.append(
@@ -141,7 +146,7 @@ def update_file(path: Path, table: str) -> bool:
if not pattern.search(content):
return False
- new_content = pattern.sub(rf"\1\n{table}\n\2", content)
+ new_content = pattern.sub(lambda m: f"{m.group(1)}\n{table}\n{m.group(2)}", content)
if new_content != content:
path.write_text(new_content)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 627ed495b6..855387c63c 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -544,6 +544,14 @@ def _add(field: str, ok: bool, msg: str, *, severity: str = "error") -> None:
_add("Preset Provides", False,
"At least one of Templates Provided or Commands Provided is required.")
# Commands Provided is optional for presets
+ # Validate scripts count if provided
+ scripts_val = fields.get("scripts_count", "").strip()
+ if scripts_val:
+ if not scripts_val.isdigit():
+ _add("Number of Scripts", False,
+ f"Number of Scripts `{scripts_val}` must be a non-negative integer.")
+ else:
+ _add("Number of Scripts", True, f"Scripts count: {scripts_val}.")
# --- Tags ---
ok, msg = validate_tags(fields.get("tags", ""))
diff --git a/README.md b/README.md
index 9784f4061d..419e7f919a 100644
--- a/README.md
+++ b/README.md
@@ -193,7 +193,6 @@ The following community-contributed extensions are available in [`catalog.commun
- `Read-only` — produces reports without modifying files
- `Read+Write` — modifies files, creates artifacts, or updates specs
-
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
@@ -275,7 +274,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
-
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
diff --git a/tests/test_catalog_validate.py b/tests/test_catalog_validate.py
index 61bc4b62a7..6e5e9840cb 100644
--- a/tests/test_catalog_validate.py
+++ b/tests/test_catalog_validate.py
@@ -3,9 +3,6 @@
from __future__ import annotations
import importlib
-import ipaddress
-import sys
-import types
from pathlib import Path
from unittest.mock import patch
From 3740d4a200989bd4a04849440d59428885fe31c4 Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 17:40:30 -0500
Subject: [PATCH 12/13] fix: address Copilot review round 11 findings
- Fix _parse_semver() return type annotation and docstring to reflect
that it returns packaging.version.Version or tuple[int, ...]
- Fail closed on DNS resolution errors in check_url_reachable() to
prevent SSRF bypass via unresolvable-then-resolvable hostnames
- Remove dead documentation field code from _build_preset_entry() since
the preset issue template has no documentation URL field
- Update catalog-generate-table.py docstring to match --target behavior
(exits with error when markers missing, not print to stdout)
- Document that extension table Category/Effect columns require catalog
schema extension to be populated
- Update presets/DEVELOPING.md tag comment from 2-5 to 2-10
---
.github/scripts/catalog-generate-table.py | 10 ++++++++--
.github/scripts/catalog-validate.py | 18 ++++++++++++------
presets/DEVELOPING.md | 2 +-
3 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/.github/scripts/catalog-generate-table.py b/.github/scripts/catalog-generate-table.py
index 0a53485df2..fc3c0666cf 100644
--- a/.github/scripts/catalog-generate-table.py
+++ b/.github/scripts/catalog-generate-table.py
@@ -2,7 +2,8 @@
"""Generate a markdown table from a community catalog JSON file.
Reads a catalog.community.json and replaces content between marker comments
-in a target markdown file. If the markers are not present the table is
+in a target markdown file. When ``--target`` is provided and markers are
+missing, the script exits with an error. Without ``--target`` the table is
printed to stdout.
Markers expected in the markdown file:
@@ -100,7 +101,12 @@ def _provides_str_extension(provides: dict) -> str:
def build_extension_table(catalog: dict) -> str:
- """Build a markdown table for extensions."""
+ """Build a markdown table for extensions.
+
+ Note: Category and Effect columns will be empty unless the catalog
+ entries include ``category`` and ``effect`` fields (not yet part of
+ the standard catalog schema).
+ """
entries = catalog.get("extensions", {})
lines: list[str] = []
lines.append("| Extension | Purpose | Category | Effect | URL |")
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 855387c63c..54e59c52f8 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -155,8 +155,12 @@ def _present(value: str | None) -> bool:
return bool(value and value.strip() and value.strip() != "_No response_")
-def _parse_semver(version: str) -> tuple[int, ...]:
- """Parse a semver string into a comparable tuple of ints."""
+def _parse_semver(version: str):
+ """Parse a version string into a comparable object.
+
+ Returns a ``packaging.version.Version`` when the library is available,
+ otherwise a tuple of ints (major, minor, patch).
+ """
try:
from packaging.version import Version
return Version(version)
@@ -300,7 +304,9 @@ def check_url_reachable(
f"{field_name} URL `{url}` resolves to a private/reserved address."
)
except (socket.gaierror, ValueError):
- pass # DNS resolution may fail for unreachable hosts — let urlopen handle it
+ return False, (
+ f"{field_name} URL `{url}` could not be resolved."
+ )
_gh_hosts = {"github.com", "www.github.com", "codeload.github.com", "raw.githubusercontent.com"}
_is_github = hostname in _gh_hosts
@@ -766,9 +772,9 @@ def _build_preset_entry(
elif is_update and "extensions" in existing.get("requires", {}):
requires["extensions"] = existing["requires"]["extensions"]
- # Documentation URL: use existing on update, fall back to repo/blob/main/README.md
- documentation = _clean(fields.get("documentation", ""))
- if not documentation and is_update:
+ # Documentation URL: preserve on update, fall back to repo/blob/main/README.md
+ documentation = ""
+ if is_update:
documentation = existing.get("documentation", "")
if not documentation:
documentation = repo + "/blob/main/README.md"
diff --git a/presets/DEVELOPING.md b/presets/DEVELOPING.md
index 2015d959f7..53d5c9b95f 100644
--- a/presets/DEVELOPING.md
+++ b/presets/DEVELOPING.md
@@ -63,7 +63,7 @@ provides:
description: "Custom spec template"
replaces: "spec-template"
-tags: # 2-5 relevant tags
+tags: # 2-10 relevant tags
- "category"
- "workflow"
```
From 199f6bfcd6239301c2e5596f1002e41cbbf2fa8c Mon Sep 17 00:00:00 2001
From: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Date: Wed, 29 Apr 2026 17:56:04 -0500
Subject: [PATCH 13/13] fix: address Copilot review round 12 findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Install packaging explicitly in both validate workflow jobs so
PEP 440 validation is consistent across runner images
- Restrict parse_issue_body() to only split on known form labels,
preventing user-typed ### headings in textareas from corrupting
field parsing
- Restrict URL reachability checks to GitHub domains only (github.com,
raw.githubusercontent.com, etc.) to mitigate DNS-rebinding TOCTOU
risks — issue templates already require GitHub URLs
- Validate, deduplicate, and sort preset requires.extensions IDs
using the same ID regex, ensuring clean catalog output
---
.github/scripts/catalog-validate.py | 43 +++++++++++++++++++-------
.github/workflows/catalog-validate.yml | 6 ++++
2 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/.github/scripts/catalog-validate.py b/.github/scripts/catalog-validate.py
index 54e59c52f8..9b64e97d98 100644
--- a/.github/scripts/catalog-validate.py
+++ b/.github/scripts/catalog-validate.py
@@ -35,12 +35,16 @@
# Issue body parser
# ---------------------------------------------------------------------------
-def parse_issue_body(body: str) -> dict[str, str]:
+def parse_issue_body(body: str, known_labels: set[str] | None = None) -> dict[str, str]:
"""Parse a GitHub issue form body into {label: value} pairs.
GitHub issue forms render as markdown with ``### Label`` headers
followed by the user's input. Checkbox groups render as lists of
``- [X]`` / ``- [ ]`` items.
+
+ When *known_labels* is provided, only ``### Label`` lines whose text
+ matches a known label start a new field. Other ``###`` headings
+ inside textarea content are preserved as-is.
"""
fields: dict[str, str] = {}
current_label: str | None = None
@@ -48,13 +52,16 @@ def parse_issue_body(body: str) -> dict[str, str]:
for line in body.splitlines():
if line.startswith("### "):
- # Store previous field
- if current_label is not None:
- fields[current_label] = "\n".join(current_lines).strip()
- current_label = line[4:].strip()
- current_lines = []
- else:
- current_lines.append(line)
+ heading = line[4:].strip()
+ # Only split on known form labels (if provided)
+ if known_labels is None or heading in known_labels:
+ # Store previous field
+ if current_label is not None:
+ fields[current_label] = "\n".join(current_lines).strip()
+ current_label = heading
+ current_lines = []
+ continue
+ current_lines.append(line)
# Don't forget the last field
if current_label is not None:
@@ -295,6 +302,17 @@ def check_url_reachable(
hostname = parsed.hostname
if not hostname:
return False, f"{field_name} URL has no hostname."
+
+ # Restrict to known hosts to mitigate DNS-rebinding TOCTOU risks
+ _allowed_hosts = {
+ "github.com", "www.github.com", "codeload.github.com",
+ "raw.githubusercontent.com", "objects.githubusercontent.com",
+ }
+ if hostname not in _allowed_hosts:
+ return False, (
+ f"{field_name} URL must be on a GitHub domain "
+ f"(got `{hostname}`)."
+ )
try:
addr_info = socket.getaddrinfo(hostname, None)
for _family, _type, _proto, _canonname, sockaddr in addr_info:
@@ -764,9 +782,11 @@ def _build_preset_entry(
for line in extensions_raw.splitlines():
line = line.strip().lstrip("-*").strip()
for part in line.split(","):
- part = part.strip()
- if part:
+ part = part.strip().lower()
+ if part and _ID_RE.match(part):
ext_list.append(part)
+ # Deduplicate and sort for stable catalog output
+ ext_list = sorted(set(ext_list))
if ext_list:
requires["extensions"] = ext_list
elif is_update and "extensions" in existing.get("requires", {}):
@@ -936,7 +956,8 @@ def main() -> None:
catalog = json.load(f)
# Parse and normalize
- raw_fields = parse_issue_body(issue_body)
+ known_labels = set(LABEL_MAPS[args.type].keys())
+ raw_fields = parse_issue_body(issue_body, known_labels=known_labels)
fields = normalize_fields(raw_fields, args.type)
if not fields:
diff --git a/.github/workflows/catalog-validate.yml b/.github/workflows/catalog-validate.yml
index 7f0cfbd467..75459d0f07 100644
--- a/.github/workflows/catalog-validate.yml
+++ b/.github/workflows/catalog-validate.yml
@@ -22,6 +22,9 @@ jobs:
with:
python-version: "3.12"
+ - name: Install dependencies
+ run: pip install packaging
+
- name: Validate submission
id: validate
env:
@@ -140,6 +143,9 @@ jobs:
with:
python-version: "3.12"
+ - name: Install dependencies
+ run: pip install packaging
+
- name: Validate submission
id: validate
env: