diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml new file mode 100644 index 0000000..9f3e039 --- /dev/null +++ b/.github/workflows/release-extension.yml @@ -0,0 +1,114 @@ +name: Release Extension + +on: + pull_request: + branches: [main] + paths: + - 'spec-kit-extensions/**' + - '.github/workflows/release-extension.yml' + push: + branches: [main] + paths: + - 'spec-kit-extensions/**' + - '.github/workflows/release-extension.yml' + tags: + - 'extension/canvas-design/v*' +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install package test dependencies + run: python -m pip install -r spec-kit-extensions/tests/requirements.txt + + - name: Validate release version + run: | + python - <<'PY' + import json + import os + import re + import yaml + + with open("spec-kit-extensions/canvas-design/extension.yml") as source: + version = yaml.safe_load(source)["extension"]["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise SystemExit(f"Invalid manifest version: {version}") + tag = f"extension/canvas-design/v{version}" + with open("spec-kit-extensions/catalog.json") as source: + entry = json.load(source)["extensions"]["canvas-design"] + if entry["version"] != version: + raise SystemExit("Catalog version must match extension.yml version") + download_url = f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/canvas-design.zip" + if entry["download_url"] != download_url: + raise SystemExit("Catalog download URL must match the release asset") + if os.environ["GITHUB_REF"].startswith("refs/tags/"): + if os.environ["GITHUB_REF"] != f"refs/tags/{tag}": + raise SystemExit("Release tag must match extension.yml version") + PY + + - name: Test package contracts + run: python -m unittest discover -s spec-kit-extensions/tests -v + + - name: Create extension ZIP + run: | + python - <<'PY' + from pathlib import Path + from zipfile import ZIP_DEFLATED, ZipFile + + package = Path("spec-kit-extensions/canvas-design") + with ZipFile("canvas-design.zip", "w", ZIP_DEFLATED) as archive: + for path in sorted(package.rglob("*")): + if path.is_symlink(): + raise SystemExit(f"Cannot package symlink: {path}") + if path.is_file(): + archive.write(path, path.relative_to(package).as_posix()) + PY + + - name: Verify release archive + env: + CANVAS_DESIGN_ARCHIVE: canvas-design.zip + run: python -m unittest discover -s spec-kit-extensions/tests -v + + - name: Upload validated archive + uses: actions/upload-artifact@v4 + with: + name: canvas-design-package + path: canvas-design.zip + if-no-files-found: error + + release: + needs: package + if: startsWith(github.ref, 'refs/tags/extension/canvas-design/v') + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: canvas-design-release + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/download-artifact@v4 + with: + name: canvas-design-package + + - name: Publish validated extension + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + gh release create "$TAG" canvas-design.zip \ + --verify-tag \ + --latest=false \ + --title "Canvas Design ${TAG##*/}" \ + --notes "Specify CLI extension: page templates and the load-page command. Requires a separate compatible Designer provider exposing speckit_designer_load_pages; this package does not ship a Designer." diff --git a/AGENTS.md b/AGENTS.md index 3aab9f8..2fe62a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,14 @@ toolchains: When revving a preset, bump `preset.yml` + the `catalog.json` entry together **before** tagging. +## Spec Kit extensions (`spec-kit-extensions/`) + +This directory and its `catalog.json` hold **Copilot-specific Specify CLI +extensions**, parallel to the preset catalog. Entries must depend on Copilot +tools or providers; do not import general-purpose extensions or add these packages +to the Copilot plugin marketplace. Keep each catalog entry's version, requirements, +and release URL aligned with its `extension.yml` and package README. + ## When revving the core skills plugin 1. Re-enumerate the `specify` CLI surface for the **latest** release diff --git a/spec-kit-extensions/.gitignore b/spec-kit-extensions/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/spec-kit-extensions/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md new file mode 100644 index 0000000..9450f96 --- /dev/null +++ b/spec-kit-extensions/README.md @@ -0,0 +1,32 @@ +# Copilot-specific Spec Kit extensions + +This catalog contains extensions that depend on Copilot-specific tools or +providers, not general-purpose Spec Kit extensions. These packages are consumed +by the **Specify CLI** (`specify extension add`), not by the Copilot plugin +marketplace. Their `catalog.json` and `extension.yml` manifests live here; +Copilot canvas providers remain under `plugins/`. + +- [Canvas Design](canvas-design/README.md) registers JSON settings pages for + a compatible Canvas Designer provider. It does not ship that provider or + register a Copilot marketplace entry. + +## Installation + +Register the catalog once, then install by ID: + +```powershell +specify extension catalog add https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json --name spec-kit-copilot --install-allowed +specify extension add canvas-design +``` + +Catalogs are discovery-only by default; `--install-allowed` permits installation. +The referenced release ZIP must be published before installation can succeed. +See the package README for provider requirements and direct-URL installation. + +## Versioning and releases + +Each extension is versioned independently in its `extension.yml`. Update the +manifest, catalog entry, and package README version together. + +The **Release Extension** workflow validates and publishes Canvas Design as +`canvas-design.zip` when an `extension/canvas-design/vX.Y.Z` tag is pushed. diff --git a/spec-kit-extensions/canvas-design/README.md b/spec-kit-extensions/canvas-design/README.md new file mode 100644 index 0000000..8a5226d --- /dev/null +++ b/spec-kit-extensions/canvas-design/README.md @@ -0,0 +1,186 @@ +# Canvas Design + +Canvas Design **0.1.0** is a Spec Kit extension requiring Specify CLI **>=1.0.7**. +It supplies JSON settings pages for the Canvas Designer; it is not a Copilot +plugin or a canvas provider. + +## Installation + +From an initialized project, register the Copilot-specific extension catalog +once, then install by ID: + +```powershell +specify extension catalog add https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json --name spec-kit-copilot --install-allowed +specify extension add canvas-design +``` + +Alternatively, install directly from a published release without registering +the catalog: + +```powershell +specify extension add canvas-design --from https://github.com/github/spec-kit-copilot/releases/download/extension/canvas-design/v0.1.0/canvas-design.zip +``` + +The release ZIP must be published before either installation method can succeed. +For a new Copilot project, initialize it with +`specify init . --integration copilot --integration-options="--skills"` first. +Normal installation copies the package; do not use a development symlink install +for preset composition. Copilot skills mode exposes the command as +`speckit-canvas-design-load-page`. Use `/skills reload` to discover newly installed +or composed skills in the current session. + +## Required Designer capability + +The package requires a separately installed, compatible Copilot Canvas Designer +provider exposing the **`speckit_designer_load_pages` custom tool** before its +panel opens. That provider/tool is **not shipped by this package**. Installing or +publishing this extension does not create a standalone Designer, and no released +Wizard version is asserted to support this page/tool protocol. + +The launching integration must supply a valid `handoffId` and, when reloading, +a `requestId`. The command resolves all pages in that session's project and +submits them together. If the tool is missing, the command must report that and +stop; it must not invent a fallback, run a Python helper, or write provider state. +Only after a successful tool result may the launching integration open +`speckit-canvas-designer` using the same handoff ID. + +## Registered pages + +| Template name | Page title | Order | Contents | +| --- | --- | --- | --- | +| `canvas-settings-setup` | Essentials | 10 | Canvas ID, Title, Description, Workflow header, Show slug field | +| `canvas-settings-artifacts` | Artifacts | 20 | Empty | +| `canvas-settings-appearance` | Appearance | 30 | Empty | +| `canvas-settings-results` | Result Badges | 40 | Empty | + +`commands/load-page.md` declares the initial page set. The agent reads the +**entire preset-composed command**, including all appended "Additional Designer +pages" sections, and resolves each name with `specify preset resolve `. +It inspects the CLI output: even exit code zero can report `not found`. Missing +templates, composition warnings and command failures stop the load; there is no +fallback to the extension's default file. + +The agent submits the complete set of `{name, path}` pairs once to +`speckit_designer_load_pages`, a **custom tool registered by the Copilot Designer +provider**, available before opening its panel. This is not a built-in Specify +command or a Python script. A compatible provider is responsible for validating +the input and storing the resulting model; this package supplies only the +command, page templates, and schema. The agent is responsible for using the +CLI-selected paths, not independently reconstructing template precedence. + +Page JSON defines its full template `id`, title, description, order, enabled state, and fields. +Enabled pages sort by order, then page ID. Fields support strings and booleans; +omitting `type` means string. A `default` is allowed only with an explicit +`"type": "boolean"`. Field IDs must be unique across enabled pages. Canvas ID and Title must remain +present. The identity fields retain their built-in constraints even when a +preset changes their labels or placement. Each file must conform to +`schemas/page.schema.json`, have an `id` equal to its supplied template name, +and resolve to a regular `.json` file inside the session project's `.specify/`. +Invalid names, escaping symlinks, unsupported controls, duplicate fields, invalid +JSON and oversized files reject the entire batch without replacing the last +valid model. +The compatible provider must enforce limits of 100 pages, 256 KiB per file, +and a 2 MiB saved model. These batch, path, uniqueness, and identity constraints +are provider requirements beyond the per-file JSON schema; they are not enforced +by installing this package alone. + +## Customize pages with a preset + +To replace Appearance, declare a JSON template in your `preset.yml`: + +```yaml +schema_version: "1.0" +preset: + id: copilot-canvas-appearance + name: Copilot Canvas Appearance + version: "1.0.0" + description: Replace the Designer Appearance page. +requires: + speckit_version: ">=1.0.7" + extensions: [canvas-design] +provides: + templates: + - type: template + name: canvas-settings-appearance + file: pages/appearance.json + strategy: replace +``` + +The JSON `id` must be `canvas-settings-appearance`. Use a small local preset for +project-level changes: `.specify/templates/overrides/` expects Markdown files, +which the JSON loader rejects. + +To add a new page, declare both its JSON template and an appended command +contribution in the preset: + +```yaml +schema_version: "1.0" +preset: + id: copilot-canvas-accessibility + name: Copilot Canvas Accessibility + version: "1.0.0" + description: Add a Designer Accessibility page. +requires: + speckit_version: ">=1.0.7" + extensions: [canvas-design] +provides: + templates: + - type: template + name: canvas-settings-accessibility + file: pages/accessibility.json + strategy: replace + - type: command + name: speckit.canvas-design.load-page + file: commands/add-pages.md + strategy: append +``` + +`commands/add-pages.md` contains instructions, without frontmatter: + +```markdown +## Additional Designer pages + +- canvas-settings-accessibility +``` + +`pages/accessibility.json` contains, for example: + +```json +{ + "schemaVersion": 1, + "id": "canvas-settings-accessibility", + "title": "Accessibility", + "order": 50, + "enabled": true, + "fields": [] +} +``` + +Append **command instructions**, not JSON content. Merely dropping a JSON file +into a directory or registering an additional template does not add it to the +command's page set. There is no automatic artifact discovery or package watcher. +After installing or removing a preset, refresh the composed skill with +`/skills reload` and use the compatible provider's explicit reload flow. +Artifacts, Appearance, and Result Badges are empty page templates. This package +does not implement UI persistence, canvas generation, or result evaluation. + +## Tests + +From the repository root, with Python 3.12 or later: + +```powershell +python -m pip install -r spec-kit-extensions\tests\requirements.txt +python -m unittest discover -s spec-kit-extensions\tests -v +``` + +These focused package tests cover manifest/catalog agreement, discovery tags, +shipped files, JSON schema acceptance/rejection, default page shape, and the +agent command contract. +The release workflow builds the ZIP inline with `extension.yml` at its root and +reruns the tests with `CANVAS_DESIGN_ARCHIVE` set to the archive path, checking the +exact member set and bytes. Set that environment variable to check a local ZIP. +No Wizard dependencies or provider are needed for these checks. + +Real Specify normal-install/preset-composition tests and consumer migration are +a separate follow-up. These package checks do not claim that the full +CLI/provider integration matrix has passed. diff --git a/spec-kit-extensions/canvas-design/commands/load-page.md b/spec-kit-extensions/canvas-design/commands/load-page.md new file mode 100644 index 0000000..e2d79eb --- /dev/null +++ b/spec-kit-extensions/canvas-design/commands/load-page.md @@ -0,0 +1,61 @@ +--- +description: Resolve Designer settings pages and load them into the Canvas Designer. +--- + +## Context + +$ARGUMENTS supplies the Wizard `handoffId` and, for an explicit reload, the +`requestId`. Preserve these values in the tool call. Work in this session's +project, not the Wizard's checkout. If the handoff ID is missing, ask for it; +do not guess or select another session's handoff. + +## Pages + +Load these default pages: + +- `canvas-settings-setup` +- `canvas-settings-artifacts` +- `canvas-settings-appearance` +- `canvas-settings-results` + +Presets may add pages in sections titled **Additional Designer pages** anywhere +in this command, including after the Steps. These additions extend the default +set; they do not run a second load operation. + +## Steps + +1. Read this entire composed command first. Collect the defaults and every name + in every **Additional Designer pages** section, removing duplicates. Page + names must start with a lowercase letter and contain only lowercase letters, + digits and hyphens (at most 80 characters). +2. Follow the `speckit-preset` skill to run `specify preset resolve ` for + each name from the project root. Resolve all pages before submitting any. + Use Specify CLI >=1.0.7. Its human-readable output looks like: + + canvas-settings-setup: C:\project\.specify\extensions\canvas-design\pages\setup.json + (top layer from: extension:canvas-design v0.1.0) + + Ignore leading indentation and record the complete path following the exact + `:` prefix. Preserve spaces and drive-letter colons; do not split on + every colon. If output wraps, rerun with a sufficiently wide `COLUMNS` + environment setting rather than guessing a truncated path. +3. Inspect the output AND exit status. `not found` can return exit code 0. + Stop on missing/ambiguous results, command errors, or a composition warning + for a page. Never choose a file by scanning `.specify`, reconstruct precedence, + or substitute an extension default. Appended instructions in this command are + allowed; composing multiple complete JSON documents for a page is not. +4. Call the custom `speckit_designer_load_pages` tool exactly once with + `handoffId`, the supplied `requestId` if present, and + `pages: [{"name": "", "path": ""}, ...]`. + Submit the entire collected set, not individual pages. This tool is provided + by the installed Canvas Designer Copilot extension, not by Specify. +5. Report the tool's result. On success, the validated model is ready for + Designer. The launching agent may now open `speckit-canvas-designer` using + the same `handoffId`; a reload updates already-open panels automatically. + Do not claim that opening or loading succeeded before the tool succeeds. + +If resolution fails before step 4, call the same tool with `handoffId`, +the supplied `requestId` if present, and `error` containing the CLI error/output +instead of `pages`. This reports failure without replacing existing pages. +Report failures unchanged. If the tool is unavailable, report that and stop; +do not run a Python helper or write the provider's state files yourself. diff --git a/spec-kit-extensions/canvas-design/extension.yml b/spec-kit-extensions/canvas-design/extension.yml new file mode 100644 index 0000000..fe7f183 --- /dev/null +++ b/spec-kit-extensions/canvas-design/extension.yml @@ -0,0 +1,37 @@ +schema_version: "1.0" + +extension: + id: canvas-design + name: Canvas Design + version: "0.1.0" + description: Registered settings pages for the Spec Kit Canvas Designer. + category: process + author: spec-kit-copilot + repository: https://github.com/github/spec-kit-copilot + license: MIT + +requires: + speckit_version: ">=1.0.7" + +provides: + commands: + - name: speckit.canvas-design.load-page + file: commands/load-page.md + description: Resolve Designer settings pages and load them into the Canvas Designer. + templates: + - name: canvas-settings-setup + file: pages/setup.json + description: Essentials fields for canvas identity and workflow naming. + - name: canvas-settings-artifacts + file: pages/artifacts.json + description: Artifacts settings placeholder. + - name: canvas-settings-appearance + file: pages/appearance.json + description: Appearance settings placeholder. + - name: canvas-settings-results + file: pages/results.json + description: Result Badges settings placeholder. + +tags: + - copilot + - canvas-design diff --git a/spec-kit-extensions/canvas-design/pages/appearance.json b/spec-kit-extensions/canvas-design/pages/appearance.json new file mode 100644 index 0000000..3d57a97 --- /dev/null +++ b/spec-kit-extensions/canvas-design/pages/appearance.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "id": "canvas-settings-appearance", + "title": "Appearance", + "description": "Configure canvas appearance.", + "order": 30, + "enabled": true, + "fields": [] +} diff --git a/spec-kit-extensions/canvas-design/pages/artifacts.json b/spec-kit-extensions/canvas-design/pages/artifacts.json new file mode 100644 index 0000000..baad864 --- /dev/null +++ b/spec-kit-extensions/canvas-design/pages/artifacts.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "id": "canvas-settings-artifacts", + "title": "Artifacts", + "description": "Configure canvas artifacts.", + "order": 20, + "enabled": true, + "fields": [] +} diff --git a/spec-kit-extensions/canvas-design/pages/results.json b/spec-kit-extensions/canvas-design/pages/results.json new file mode 100644 index 0000000..3ed1331 --- /dev/null +++ b/spec-kit-extensions/canvas-design/pages/results.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "id": "canvas-settings-results", + "title": "Result Badges", + "description": "Configure canvas result badges.", + "order": 40, + "enabled": true, + "fields": [] +} diff --git a/spec-kit-extensions/canvas-design/pages/setup.json b/spec-kit-extensions/canvas-design/pages/setup.json new file mode 100644 index 0000000..064aac6 --- /dev/null +++ b/spec-kit-extensions/canvas-design/pages/setup.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "id": "canvas-settings-setup", + "title": "Essentials", + "description": "Configure your canvas.", + "order": 10, + "enabled": true, + "fields": [ + {"id": "canvas.id", "label": "Canvas ID"}, + {"id": "canvas.displayName", "label": "Title"}, + {"id": "canvas.description", "label": "Description"}, + {"id": "canvas.workflowListName", "label": "Workflow header"}, + {"id": "workflowSlug.userProvided", "label": "Show slug field", "type": "boolean"} + ] +} diff --git a/spec-kit-extensions/canvas-design/schemas/page.schema.json b/spec-kit-extensions/canvas-design/schemas/page.schema.json new file mode 100644 index 0000000..fc5129f --- /dev/null +++ b/spec-kit-extensions/canvas-design/schemas/page.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "title", "order", "fields"], + "properties": { + "schemaVersion": {"const": 1}, + "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,79}$"}, + "title": {"type": "string", "minLength": 1, "maxLength": 120}, + "description": {"type": "string", "maxLength": 1000}, + "order": {"type": "integer", "minimum": -100000, "maximum": 100000}, + "enabled": {"type": "boolean"}, + "fields": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label"], + "dependentSchemas": { + "default": { + "required": ["type"], + "properties": {"type": {"const": "boolean"}} + } + }, + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,79}$"}, + "label": {"type": "string", "minLength": 1, "maxLength": 120}, + "description": {"type": "string", "maxLength": 1000}, + "type": {"enum": ["string", "boolean"]}, + "default": {"type": "boolean"} + } + } + } + } +} diff --git a/spec-kit-extensions/catalog.json b/spec-kit-extensions/catalog.json new file mode 100644 index 0000000..799d3d8 --- /dev/null +++ b/spec-kit-extensions/catalog.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-09-30T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit-copilot/main/spec-kit-extensions/catalog.json", + "extensions": { + "canvas-design": { + "name": "Canvas Design", + "id": "canvas-design", + "version": "0.1.0", + "description": "Settings pages for the Copilot Canvas Designer; requires its speckit_designer_load_pages tool.", + "author": "spec-kit-copilot", + "repository": "https://github.com/github/spec-kit-copilot", + "download_url": "https://github.com/github/spec-kit-copilot/releases/download/extension/canvas-design/v0.1.0/canvas-design.zip", + "homepage": "https://github.com/github/spec-kit-copilot", + "documentation": "https://github.com/github/spec-kit-copilot/blob/main/spec-kit-extensions/canvas-design/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=1.0.7" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "copilot", + "canvas-design" + ] + } + } +} diff --git a/spec-kit-extensions/tests/requirements.txt b/spec-kit-extensions/tests/requirements.txt new file mode 100644 index 0000000..a6e9bc4 --- /dev/null +++ b/spec-kit-extensions/tests/requirements.txt @@ -0,0 +1,2 @@ +PyYAML>=6.0.2,<7 +jsonschema>=4.18,<5 diff --git a/spec-kit-extensions/tests/test_canvas_design.py b/spec-kit-extensions/tests/test_canvas_design.py new file mode 100644 index 0000000..0a5854e --- /dev/null +++ b/spec-kit-extensions/tests/test_canvas_design.py @@ -0,0 +1,366 @@ +import copy +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from zipfile import ZipFile + +import yaml +from jsonschema import Draft202012Validator, ValidationError + + +EXTENSIONS = Path(__file__).resolve().parents[1] +PACKAGE = EXTENSIONS / "canvas-design" +PAGE_NAMES = ("setup", "artifacts", "appearance", "results") +FILES = { + "extension.yml", + "README.md", + "commands/load-page.md", + "schemas/page.schema.json", + *(f"pages/{name}.json" for name in PAGE_NAMES), +} + + +class CanvasDesignPackageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.manifest = yaml.safe_load((PACKAGE / "extension.yml").read_text("utf-8")) + cls.catalog = json.loads((EXTENSIONS / "catalog.json").read_text("utf-8")) + cls.schema = json.loads((PACKAGE / "schemas/page.schema.json").read_text("utf-8")) + cls.validator = Draft202012Validator(cls.schema) + cls.pages = [ + json.loads((PACKAGE / f"pages/{name}.json").read_text("utf-8")) + for name in PAGE_NAMES + ] + cls.command = (PACKAGE / "commands/load-page.md").read_text("utf-8") + cls.workflow = yaml.load( + (EXTENSIONS.parent / ".github/workflows/release-extension.yml").read_text("utf-8"), + Loader=yaml.BaseLoader, + ) + + def workflow_python(self, step_name): + step = next( + step for step in self.workflow["jobs"]["package"]["steps"] + if step.get("name") == step_name + ) + lines = step["run"].strip().splitlines() + self.assertEqual(lines[0], "python - <<'PY'") + self.assertEqual(lines[-1], "PY") + return "\n".join(lines[1:-1]) + + def test_manifest_and_shipped_files(self): + self.assertEqual(self.manifest["schema_version"], "1.0") + extension = self.manifest["extension"] + self.assertEqual(extension["id"], "canvas-design") + self.assertRegex(extension["version"], r"^\d+\.\d+\.\d+$") + self.assertIn( + f'Canvas Design **{extension["version"]}**', + (PACKAGE / "README.md").read_text("utf-8"), + ) + self.assertEqual(self.manifest["requires"], {"speckit_version": ">=1.0.7"}) + self.assertEqual( + set(self.manifest["provides"]), {"commands", "templates"} + ) + self.assertEqual( + [(command["name"], command["file"]) + for command in self.manifest["provides"]["commands"]], + [("speckit.canvas-design.load-page", "commands/load-page.md")], + ) + self.assertEqual( + [(template["name"], template["file"]) + for template in self.manifest["provides"]["templates"]], + [(f"canvas-settings-{name}", f"pages/{name}.json") for name in PAGE_NAMES], + ) + actual_files = set() + for path in PACKAGE.rglob("*"): + self.assertFalse(path.is_symlink(), f"Package symlink: {path}") + if path.is_file(): + actual_files.add(path.relative_to(PACKAGE).as_posix()) + self.assertEqual(actual_files, FILES) + for declaration in ( + self.manifest["provides"]["commands"] + + self.manifest["provides"]["templates"] + ): + path = PACKAGE / declaration["file"] + self.assertTrue(path.resolve().is_relative_to(PACKAGE.resolve())) + self.assertTrue(path.is_file(), declaration["file"]) + + def test_copilot_catalog_matches_package(self): + self.assertEqual(self.catalog["schema_version"], "1.0") + catalog_url = ( + "https://raw.githubusercontent.com/github/spec-kit-copilot/main/" + "spec-kit-extensions/catalog.json" + ) + self.assertEqual(self.catalog["catalog_url"], catalog_url) + self.assertEqual(set(self.catalog["extensions"]), {"canvas-design"}) + entry = self.catalog["extensions"]["canvas-design"] + for field in ("id", "name", "version", "author", "repository", "license"): + with self.subTest(field=field): + self.assertEqual(entry[field], self.manifest["extension"][field]) + self.assertEqual(entry["requires"], self.manifest["requires"]) + self.assertEqual(entry["provides"], { + "commands": len(self.manifest["provides"]["commands"]), + "hooks": 0, + }) + self.assertEqual(entry["tags"], ["copilot", "canvas-design"]) + self.assertEqual(self.manifest["tags"], entry["tags"]) + self.assertIn("Copilot", entry["description"]) + self.assertIn("speckit_designer_load_pages", entry["description"]) + version = entry["version"] + self.assertEqual( + entry["download_url"], + "https://github.com/github/spec-kit-copilot/releases/download/" + f"extension/canvas-design/v{version}/canvas-design.zip", + ) + self.assertEqual( + entry["documentation"], + "https://github.com/github/spec-kit-copilot/blob/main/" + "spec-kit-extensions/canvas-design/README.md", + ) + for path in (EXTENSIONS / "README.md", PACKAGE / "README.md"): + with self.subTest(readme=path): + readme = path.read_text("utf-8") + self.assertIn( + f"specify extension catalog add {catalog_url} " + "--name spec-kit-copilot --install-allowed", readme, + ) + self.assertIn("specify extension add canvas-design\n", readme) + + def test_schema_and_default_pages(self): + Draft202012Validator.check_schema(self.schema) + self.assertEqual( + self.schema["$schema"], "https://json-schema.org/draft/2020-12/schema" + ) + for index, page in enumerate(self.pages): + with self.subTest(page=page["id"]): + self.validator.validate(page) + self.assertEqual(page["id"], f"canvas-settings-{PAGE_NAMES[index]}") + self.assertEqual(page["order"], (index + 1) * 10) + self.assertTrue(page["enabled"]) + self.assertEqual( + [page["title"] for page in self.pages], + ["Essentials", "Artifacts", "Appearance", "Result Badges"], + ) + self.assertEqual( + self.pages[0]["fields"], + [ + {"id": "canvas.id", "label": "Canvas ID"}, + {"id": "canvas.displayName", "label": "Title"}, + {"id": "canvas.description", "label": "Description"}, + {"id": "canvas.workflowListName", "label": "Workflow header"}, + {"id": "workflowSlug.userProvided", "label": "Show slug field", + "type": "boolean"}, + ], + ) + self.assertTrue(all(page["fields"] == [] for page in self.pages[1:])) + field_ids = [field["id"] for page in self.pages for field in page["fields"]] + self.assertEqual(len(field_ids), len(set(field_ids))) + + def test_schema_rejects_invalid_page_shapes(self): + mutations = { + "schema version": {"schemaVersion": 2}, + "invalid id": {"id": "../outside"}, + "long id": {"id": "a" * 81}, + "empty title": {"title": ""}, + "long title": {"title": "a" * 121}, + "long description": {"description": "a" * 1001}, + "fractional order": {"order": 1.5}, + "large order": {"order": 100001}, + "small order": {"order": -100001}, + "enabled type": {"enabled": "true"}, + "unknown property": {"html": ""}, + "too many fields": {"fields": [{"id": "x", "label": "X"}] * 101}, + "unknown field property": {"fields": [{"id": "x", "label": "X", "html": ""}]}, + "missing label": {"fields": [{"id": "x"}]}, + "invalid field id": {"fields": [{"id": "1x", "label": "X"}]}, + "unsupported control": {"fields": [{"id": "x", "label": "X", "type": "html"}]}, + "default type": {"fields": [{"id": "x", "label": "X", "default": "true"}]}, + } + for name, mutation in mutations.items(): + with self.subTest(case=name): + page = copy.deepcopy(self.pages[0]) + page.update(mutation) + with self.assertRaises(ValidationError): + self.validator.validate(page) + for required in ("schemaVersion", "id", "title", "order", "fields"): + with self.subTest(missing=required): + page = copy.deepcopy(self.pages[0]) + del page[required] + with self.assertRaises(ValidationError): + self.validator.validate(page) + + def test_schema_accepts_optional_field_types_and_disabled_pages(self): + page = copy.deepcopy(self.pages[0]) + page["enabled"] = False + page["fields"] = [ + {"id": "custom.text", "label": "Text", "type": "string"}, + {"id": "custom.flag", "label": "Flag", "type": "boolean", "default": True}, + ] + self.validator.validate(page) + del page["enabled"] + self.validator.validate(page) + + def test_defaults_require_explicit_boolean_type(self): + for field_type in (None, "string", "boolean"): + for default in (True, False): + with self.subTest(field_type=field_type, default=default): + page = copy.deepcopy(self.pages[0]) + field = {"id": "custom.field", "label": "Custom", "default": default} + if field_type is not None: + field["type"] = field_type + page["fields"] = [field] + if field_type == "boolean": + self.validator.validate(page) + else: + with self.assertRaises(ValidationError): + self.validator.validate(page) + + def test_command_contract(self): + frontmatter = self.command.split("---", 2) + self.assertEqual(frontmatter[0], "") + metadata = yaml.safe_load(frontmatter[1]) + self.assertEqual( + metadata["description"], + self.manifest["provides"]["commands"][0]["description"], + ) + defaults = re.findall(r"^- `(canvas-settings-[a-z]+)`$", self.command, re.M) + self.assertEqual(defaults, [f"canvas-settings-{name}" for name in PAGE_NAMES]) + normalized = " ".join(self.command.split()) + for required in ( + "$ARGUMENTS", "`handoffId`", "`requestId`", + "Additional Designer pages", "removing duplicates", + "specify preset resolve ", "Resolve all pages before submitting any", + "Preserve spaces and drive-letter colons", + "not found` can return exit code 0", + "Stop on missing/ambiguous results", + "Never choose a file by scanning", + "Call the custom `speckit_designer_load_pages` tool exactly once", + 'pages: [{"name": "", "path": ""}, ...]', + "Submit the entire collected set", + "`error` containing the CLI error/output instead of `pages`", + "If the tool is unavailable, report that and stop", + "do not run a Python helper or write the provider's state files yourself", + "Do not claim that opening or loading succeeded before the tool succeeds", + ): + with self.subTest(contract=required): + self.assertIn(required, normalized) + + def test_documented_normal_install_and_provider_boundary(self): + readme = (PACKAGE / "README.md").read_text("utf-8") + version = self.manifest["extension"]["version"] + self.assertIn( + "specify extension add canvas-design --from " + "https://github.com/github/spec-kit-copilot/releases/download/" + f"extension/canvas-design/v{version}/canvas-design.zip", + readme, + ) + self.assertIn('--integration copilot --integration-options="--skills"', readme) + self.assertNotIn("--dev", readme) + self.assertIn("not shipped by this package", readme) + self.assertIn("no released\nWizard version is asserted", readme) + + @unittest.skipUnless(os.environ.get("CANVAS_DESIGN_ARCHIVE"), "No release ZIP supplied") + def test_release_archive_has_exact_package_bytes(self): + self.assert_archive_matches_package(os.environ["CANVAS_DESIGN_ARCHIVE"]) + + def test_inline_workflow_packaging(self): + with tempfile.TemporaryDirectory(prefix="canvas-design-package-") as temporary: + root = Path(temporary) + shutil.copytree(PACKAGE, root / "spec-kit-extensions/canvas-design") + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Create extension ZIP")], + cwd=root, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assert_archive_matches_package(root / "canvas-design.zip") + + def test_release_version_guards(self): + version = self.manifest["extension"]["version"] + tag = f"refs/tags/extension/canvas-design/v{version}" + cases = [ + ("refs/pull/1/merge", True), + ("refs/heads/main", True), + (tag, True), + ("refs/tags/extension/canvas-design/v999.0.0", False), + ("refs/tags/canvas-design-v0.1.0", False), + ] + for ref, succeeds in cases: + with self.subTest(ref=ref): + env = dict(os.environ, GITHUB_REF=ref) + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Validate release version")], + cwd=EXTENSIONS.parent, env=env, capture_output=True, text=True, + ) + self.assertEqual(result.returncode == 0, succeeds, + result.stdout + result.stderr) + + def test_release_triggers_and_permissions(self): + triggers = self.workflow["on"] + self.assertEqual(set(triggers), {"pull_request", "push"}) + self.assertEqual(triggers["push"]["tags"], ["extension/canvas-design/v*"]) + self.assertEqual(triggers["pull_request"]["branches"], ["main"]) + self.assertEqual(self.workflow["permissions"], {"contents": "read"}) + release = self.workflow["jobs"]["release"] + self.assertEqual(release["needs"], "package") + self.assertEqual(release["permissions"], {"contents": "write"}) + self.assertEqual( + release["if"], + "startsWith(github.ref, 'refs/tags/extension/canvas-design/v')", + ) + publish = next( + step for step in release["steps"] + if step.get("name") == "Publish validated extension" + )["run"] + self.assertIn('TAG="${GITHUB_REF#refs/tags/}"', publish) + self.assertIn('gh release create "$TAG" canvas-design.zip', publish) + self.assertIn("--verify-tag", publish) + for step in release["steps"]: + self.assertNotRegex(step.get("run", ""), r"\bgit\s+(tag|push|ls-remote)\b") + + def test_release_rejects_catalog_drift(self): + version = self.manifest["extension"]["version"] + mutations = [ + ("version", "999.0.0", "Catalog version must match"), + ("download_url", "https://example.com/wrong.zip", "Catalog download URL must match"), + ] + with tempfile.TemporaryDirectory(prefix="canvas-design-catalog-") as temporary: + root = Path(temporary) + package = root / "spec-kit-extensions/canvas-design" + package.mkdir(parents=True) + shutil.copyfile(PACKAGE / "extension.yml", package / "extension.yml") + for field, value, error in mutations: + with self.subTest(field=field): + catalog = copy.deepcopy(self.catalog) + catalog["extensions"]["canvas-design"][field] = value + (package.parent / "catalog.json").write_text(json.dumps(catalog), "utf-8") + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Validate release version")], + cwd=root, + env=dict(os.environ, GITHUB_REF=f"refs/tags/extension/canvas-design/v{version}"), + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(error, result.stderr) + + def assert_archive_matches_package(self, path): + with ZipFile(path) as archive: + members = archive.infolist() + self.assertEqual(len(members), len(FILES), "Duplicate or extra ZIP entries") + self.assertEqual({entry.filename for entry in members}, FILES) + self.assertIsNone(archive.testzip()) + for entry in members: + with self.subTest(member=entry.filename): + self.assertFalse(entry.is_dir()) + self.assertEqual( + archive.read(entry), + (PACKAGE / entry.filename).read_bytes(), + ) + + +if __name__ == "__main__": + unittest.main()