diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml new file mode 100644 index 00000000..6b7882d5 --- /dev/null +++ b/.github/workflows/release-extension-trigger.yml @@ -0,0 +1,26 @@ +name: Release Extension Trigger + +on: + workflow_dispatch: + inputs: + extension_id: + description: 'Extension directory name under spec-kit-extensions/ (e.g., extension-canvas-design)' + required: true + type: string + version: + description: 'Version to release (e.g., 0.1.0; optional v prefix)' + required: true + type: string + +permissions: + contents: read + +jobs: + tag-and-release: + permissions: + contents: write + # Call the publisher directly: GITHUB_TOKEN tag pushes do not start workflows. + uses: ./.github/workflows/release-extension.yml + with: + extension_id: ${{ inputs.extension_id }} + version: ${{ inputs.version }} diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 9f3e0397..a34ccc51 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -1,24 +1,38 @@ name: Release Extension on: + workflow_call: + inputs: + extension_id: + required: true + type: string + version: + required: true + type: string pull_request: branches: [main] paths: - 'spec-kit-extensions/**' - '.github/workflows/release-extension.yml' + - '.github/workflows/release-extension-trigger.yml' push: branches: [main] paths: - 'spec-kit-extensions/**' - '.github/workflows/release-extension.yml' + - '.github/workflows/release-extension-trigger.yml' tags: - - 'extension/canvas-design/v*' + - 'extension-*-v*' permissions: contents: read jobs: package: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.validate.outputs.tag }} + extension_id: ${{ steps.validate.outputs.extension_id }} + extension_name: ${{ steps.validate.outputs.extension_name }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,88 +41,180 @@ jobs: with: python-version: '3.12' - - name: Install package test dependencies - run: python -m pip install -r spec-kit-extensions/tests/requirements.txt + - name: Install manifest parser + run: python -m pip install "PyYAML>=6.0.2,<7" - name: Validate release version + id: validate + env: + EXTENSION_ID: ${{ inputs.extension_id }} + VERSION: ${{ inputs.version }} run: | python - <<'PY' import json import os import re + from pathlib import Path 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}": + root = Path("spec-kit-extensions") + ref = os.environ["GITHUB_REF"] + selected_id = "" + requested_version = None + if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch": + selected_id = os.environ["EXTENSION_ID"] + if not selected_id: + raise SystemExit("Extension ID is required") + requested_version = os.environ["VERSION"].removeprefix("v") + elif ref.startswith("refs/tags/"): + match = re.fullmatch(r"refs/tags/(extension-[a-z0-9]+(?:-[a-z0-9]+)*)-v([0-9]+\.[0-9]+\.[0-9]+)", ref) + if not match: + raise SystemExit("Invalid extension release tag") + selected_id, requested_version = match.groups() + extension_ids = [selected_id] if selected_id else sorted( + path.parent.name for path in root.glob("*/extension.yml") + ) + if not extension_ids: + raise SystemExit("No extension manifests found") + with open(root / "catalog.json") as source: + catalog = json.load(source)["extensions"] + outputs = {"extension_ids": json.dumps(extension_ids)} + for extension_id in extension_ids: + if not re.fullmatch(r"extension-[a-z0-9]+(?:-[a-z0-9]+)*", extension_id): + raise SystemExit(f"Invalid extension ID: {extension_id}") + package = root / extension_id + if package.is_symlink() or not (package / "extension.yml").is_file(): + raise SystemExit(f"Extension directory must contain extension.yml: {extension_id}") + with open(package / "extension.yml") as source: + manifest = yaml.safe_load(source) + extension = manifest["extension"] + if extension["id"] != extension_id: + raise SystemExit("Manifest ID must match extension directory") + version = extension["version"] + if not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + raise SystemExit(f"Invalid manifest version: {version}") + if requested_version is not None and requested_version != version: + raise SystemExit("Requested version must match extension.yml version") + if extension_id not in catalog: + raise SystemExit(f"Extension is missing from catalog: {extension_id}") + entry = catalog[extension_id] + if entry["id"] != extension_id: + raise SystemExit("Catalog ID must match extension directory") + if entry["version"] != version: + raise SystemExit("Catalog version must match extension.yml version") + if entry["requires"] != manifest["requires"]: + raise SystemExit("Catalog requirements must match extension.yml requirements") + tag = f"{extension_id}-v{version}" + download_url = f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/{extension_id}.zip" + if entry["download_url"] != download_url: + raise SystemExit("Catalog download URL must match the release asset") + if ref.startswith("refs/tags/") and ref != f"refs/tags/{tag}": raise SystemExit("Release tag must match extension.yml version") + if selected_id: + name = extension["name"] + if not isinstance(name, str) or not name.strip() or "\n" in name or "\r" in name: + raise SystemExit("Extension name must be a nonempty single line") + outputs.update(tag=tag, extension_id=extension_id, extension_name=name) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for key, value in outputs.items(): + output.write(f"{key}={value}\n") PY - - name: Test package contracts - run: python -m unittest discover -s spec-kit-extensions/tests -v - - name: Create extension ZIP + env: + EXTENSION_IDS: ${{ steps.validate.outputs.extension_ids }} run: | python - <<'PY' - from pathlib import Path + import json + import os + from pathlib import Path, PurePosixPath from zipfile import ZIP_DEFLATED, ZipFile + import yaml - package = Path("spec-kit-extensions/canvas-design") - with ZipFile("canvas-design.zip", "w", ZIP_DEFLATED) as archive: + for extension_id in json.loads(os.environ["EXTENSION_IDS"]): + package = Path("spec-kit-extensions") / extension_id + files = {} 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()) + files[path.relative_to(package).as_posix()] = path + with open(package / "extension.yml") as source: + manifest = yaml.safe_load(source) + for kind, declarations in manifest.get("provides", {}).items(): + for declaration in declarations: + for field in ("file", "template"): + if field not in declaration: + continue + name = declaration[field] + if not isinstance(name, str) or PurePosixPath(name).as_posix() not in files: + raise SystemExit( + f"Declared {kind} {field} is not a regular package file: " + f"{extension_id}/{name!r}" + ) + with ZipFile(f"{extension_id}.zip", "w", ZIP_DEFLATED) as archive: + for name, path in files.items(): + archive.write(path, name) + with ZipFile(f"{extension_id}.zip") as archive: + if archive.namelist() != list(files) or archive.testzip() is not None: + raise SystemExit(f"Invalid release archive: {extension_id}") + for name, path in files.items(): + if archive.read(name) != path.read_bytes(): + raise SystemExit(f"Archive content mismatch: {extension_id}/{name}") 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 + name: extension-packages + path: '*.zip' if-no-files-found: error release: needs: package - if: startsWith(github.ref, 'refs/tags/extension/canvas-design/v') + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/extension-') runs-on: ubuntu-latest permissions: contents: write concurrency: - group: canvas-design-release + group: extension-release-${{ needs.package.outputs.extension_id }} cancel-in-progress: false steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: actions/download-artifact@v4 with: - name: canvas-design-package + name: extension-packages + + - name: Create or verify release tag + env: + TAG: ${{ needs.package.outputs.tag }} + run: | + if git show-ref --verify --quiet "refs/tags/$TAG"; then + if [[ "$(git rev-parse "refs/tags/$TAG^{commit}")" != "$(git rev-parse HEAD)" ]]; then + echo "Error: Tag '$TAG' points to a different commit; refusing to move it." >&2 + exit 1 + fi + elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + git tag "$TAG" HEAD + git push origin "refs/tags/$TAG" + else + echo "Error: Release tag '$TAG' is missing." >&2 + exit 1 + fi - name: Publish validated extension env: GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.package.outputs.tag }} + EXTENSION_ID: ${{ needs.package.outputs.extension_id }} + EXTENSION_NAME: ${{ needs.package.outputs.extension_name }} run: | - TAG="${GITHUB_REF#refs/tags/}" - gh release create "$TAG" canvas-design.zip \ + gh release create "$TAG" "$EXTENSION_ID.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." + --title "$EXTENSION_ID ${TAG##*-}" \ + --notes "Specify CLI extension: $EXTENSION_NAME. See spec-kit-extensions/$EXTENSION_ID/README.md for installation and requirements." diff --git a/.github/workflows/release-preset.yml b/.github/workflows/release-preset.yml index 7f5e5118..ff48d3f8 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -8,6 +8,7 @@ on: push: tags: - '*-v[0-9]+.[0-9]+.[0-9]+' + - '!extension-*' jobs: release: diff --git a/AGENTS.md b/AGENTS.md index 2fe62a4a..3b779732 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,12 @@ 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. +Package IDs, directory names, catalog keys, and ZIP basenames use +`extension-` (for example, `extension-canvas-design`). Release tags use +`-vX.Y.Z` and release titles use ` vX.Y.Z`, matching +the preset version suffix. Keep discovery tags such as `canvas-design`, template +IDs such as `canvas-settings-*`, and Copilot provider/tool IDs independent of +the package identity. Standard manifest filenames remain `extension.yml`. ## When revving the core skills plugin diff --git a/spec-kit-extensions/.gitignore b/spec-kit-extensions/.gitignore deleted file mode 100644 index c18dd8d8..00000000 --- a/spec-kit-extensions/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__/ diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index 9450f963..06abbf8c 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -6,7 +6,7 @@ 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 +- [Canvas Design](extension-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. @@ -16,7 +16,7 @@ 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 +specify extension add extension-canvas-design ``` Catalogs are discovery-only by default; `--install-allowed` permits installation. @@ -27,6 +27,49 @@ See the package README for provider requirements and direct-URL installation. Each extension is versioned independently in its `extension.yml`. Update the manifest, catalog entry, and package README version together. +Package IDs, directory names, and ZIP names use `extension-`. +Tags use `-vX.Y.Z`, matching the preset version suffix, and release +titles use ` vX.Y.Z`. For example, version `0.1.0` of +`extension-canvas-design` is tagged `extension-canvas-design-v0.1.0` and ships +`extension-canvas-design.zip`. Standard filenames such as `extension.yml` and +`README.md` are unchanged. The discovery tag `canvas-design` is independent of +the package ID and remains unchanged. -The **Release Extension** workflow validates and publishes Canvas Design as -`canvas-design.zip` when an `extension/canvas-design/vX.Y.Z` tag is pushed. +To publish an extension from the GitHub Actions UI after merging those updates: + +1. Open **Actions** in `github/spec-kit-copilot`. +2. Select **Release Extension Trigger**, then **Run workflow**. +3. Leave **Use workflow from** set to **main**, enter the extension's directory + name under `spec-kit-extensions/` (for example, `extension-canvas-design`), and enter + its manifest version (for example, `0.1.0`; an optional `v` prefix is accepted). +4. Click **Run workflow** and monitor its packaging and release jobs. + +The trigger calls the reusable **Release Extension** workflow as part of the +same run. GitHub's built-in `GITHUB_TOKEN` can create tags and publish releases, +but tags pushed with it do not automatically start another workflow. Calling +the publisher directly avoids that limitation; no personal access token is +needed. The publisher validates the requested extension/version, manifest, +catalog version and download URL, builds and verifies the ZIP, then creates +`-vX.Y.Z` at the selected commit +and publishes `.zip`. The extension must have an `extension.yml` +and a matching entry in this directory's `catalog.json`; no workflow edit is +needed when adding another extension. + +Packaging rejects missing or non-file assets declared under `provides`, +including command files, templates, and configuration templates. The ZIP is +verified against the complete package file inventory before a tag is created. + +Direct pushes of `extension--vX.Y.Z` tags run the same publisher +for that extension. Pull requests and relevant pushes to `main` validate and +package every extension directory containing `extension.yml`; they do not +create tags or releases. +The preset publisher excludes `extension-*` tags so it does not attempt to +publish an extension as a preset. + +If tagging succeeds but no release is created, use **Re-run failed jobs** on the +original Actions run to retry the same commit. An existing tag is reused only +if it points to that commit; a tag pointing elsewhere is rejected and never +moved. Publication uses the same single `gh release create` command as preset +releases; existing releases are not overwritten or repaired automatically. +If a code fix is needed after tagging, release a new version instead of moving +the old tag. diff --git a/spec-kit-extensions/canvas-design/README.md b/spec-kit-extensions/canvas-design/README.md deleted file mode 100644 index 8a5226d8..00000000 --- a/spec-kit-extensions/canvas-design/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# 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/catalog.json b/spec-kit-extensions/catalog.json index 799d3d81..03846258 100644 --- a/spec-kit-extensions/catalog.json +++ b/spec-kit-extensions/catalog.json @@ -3,16 +3,16 @@ "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": { + "extension-canvas-design": { "name": "Canvas Design", - "id": "canvas-design", + "id": "extension-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", + "download_url": "https://github.com/github/spec-kit-copilot/releases/download/extension-canvas-design-v0.1.0/extension-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", + "documentation": "https://github.com/github/spec-kit-copilot/blob/main/spec-kit-extensions/extension-canvas-design/README.md", "license": "MIT", "requires": { "speckit_version": ">=1.0.7" diff --git a/spec-kit-extensions/extension-canvas-design/README.md b/spec-kit-extensions/extension-canvas-design/README.md new file mode 100644 index 00000000..40492e2b --- /dev/null +++ b/spec-kit-extensions/extension-canvas-design/README.md @@ -0,0 +1,74 @@ +# Canvas Design + +A Spec Kit extension that supplies settings-page templates and a page-loading +command for a compatible Copilot Canvas Designer. + +## What It Does + +Canvas Design **0.1.0** registers four JSON page templates and the +`speckit.extension-canvas-design.load-page` command. The command resolves the +project's preset-composed pages and submits them to the Designer provider. + +| Template | Page | Default contents | +| --- | --- | --- | +| `canvas-settings-setup` | Essentials | Canvas ID, Title, Description, Workflow header, Show slug field | +| `canvas-settings-artifacts` | Artifacts | Empty placeholder | +| `canvas-settings-appearance` | Appearance | Empty placeholder | +| `canvas-settings-results` | Result Badges | Empty placeholder | + +The package includes the page schema, but not the Designer provider. It does not +implement canvas generation, persistence, or result evaluation. + +## Requirements + +- Specify CLI **>=1.0.7** and an initialized Spec Kit project. +- GitHub Copilot with a separately installed, compatible Canvas Designer + provider exposing `speckit_designer_load_pages`. +- A launching integration that supplies the Designer handoff. + +Installing this extension does not install or open a Designer. Compatibility +with a released Wizard version is not established by this package. + +## Installation + +**Recommended: register the catalog once, then install by ID.** Catalogs are +discovery-only by default; `--install-allowed` permits installation: + +```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 extension-canvas-design +``` + +For a one-off installation without registering the catalog, use the release ZIP: + +```powershell +specify extension add extension-canvas-design --from https://github.com/github/spec-kit-copilot/releases/download/extension-canvas-design-v0.1.0/extension-canvas-design.zip +``` + +The ZIP must be published before either installation method can succeed. +For a new Copilot project, initialize it first: + +```powershell +specify init . --integration copilot --integration-options="--skills" +``` + +Use normal installation rather than a development symlink for preset composition. +In Copilot skills mode, the command is exposed as +`speckit-extension-canvas-design-load-page`. Run `/skills reload` after installing +or changing composed skills to make them available in the current session. + +## How It Works + +The [page-loading command](commands/load-page.md) collects the default template +names and any additional names contributed by presets. It uses +`specify preset resolve ` to find each project's effective page, then +submits the complete set to the compatible Designer provider. Missing pages or +an unavailable provider stop the operation. + +Presets can replace an existing page template or append instructions that add +pages to the command. Adding a JSON file alone does not register a new page. +Page definitions must follow the [page schema](schemas/page.schema.json). + +## License + +MIT diff --git a/spec-kit-extensions/canvas-design/commands/load-page.md b/spec-kit-extensions/extension-canvas-design/commands/load-page.md similarity index 94% rename from spec-kit-extensions/canvas-design/commands/load-page.md rename to spec-kit-extensions/extension-canvas-design/commands/load-page.md index e2d79ebb..71782ac3 100644 --- a/spec-kit-extensions/canvas-design/commands/load-page.md +++ b/spec-kit-extensions/extension-canvas-design/commands/load-page.md @@ -32,8 +32,8 @@ set; they do not run a second load operation. 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) + canvas-settings-setup: C:\project\.specify\extensions\extension-canvas-design\pages\setup.json + (top layer from: extension: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 diff --git a/spec-kit-extensions/canvas-design/extension.yml b/spec-kit-extensions/extension-canvas-design/extension.yml similarity index 92% rename from spec-kit-extensions/canvas-design/extension.yml rename to spec-kit-extensions/extension-canvas-design/extension.yml index fe7f1835..4059b703 100644 --- a/spec-kit-extensions/canvas-design/extension.yml +++ b/spec-kit-extensions/extension-canvas-design/extension.yml @@ -1,7 +1,7 @@ schema_version: "1.0" extension: - id: canvas-design + id: extension-canvas-design name: Canvas Design version: "0.1.0" description: Registered settings pages for the Spec Kit Canvas Designer. @@ -15,7 +15,7 @@ requires: provides: commands: - - name: speckit.canvas-design.load-page + - name: speckit.extension-canvas-design.load-page file: commands/load-page.md description: Resolve Designer settings pages and load them into the Canvas Designer. templates: diff --git a/spec-kit-extensions/canvas-design/pages/appearance.json b/spec-kit-extensions/extension-canvas-design/pages/appearance.json similarity index 100% rename from spec-kit-extensions/canvas-design/pages/appearance.json rename to spec-kit-extensions/extension-canvas-design/pages/appearance.json diff --git a/spec-kit-extensions/canvas-design/pages/artifacts.json b/spec-kit-extensions/extension-canvas-design/pages/artifacts.json similarity index 100% rename from spec-kit-extensions/canvas-design/pages/artifacts.json rename to spec-kit-extensions/extension-canvas-design/pages/artifacts.json diff --git a/spec-kit-extensions/canvas-design/pages/results.json b/spec-kit-extensions/extension-canvas-design/pages/results.json similarity index 100% rename from spec-kit-extensions/canvas-design/pages/results.json rename to spec-kit-extensions/extension-canvas-design/pages/results.json diff --git a/spec-kit-extensions/canvas-design/pages/setup.json b/spec-kit-extensions/extension-canvas-design/pages/setup.json similarity index 100% rename from spec-kit-extensions/canvas-design/pages/setup.json rename to spec-kit-extensions/extension-canvas-design/pages/setup.json diff --git a/spec-kit-extensions/canvas-design/schemas/page.schema.json b/spec-kit-extensions/extension-canvas-design/schemas/page.schema.json similarity index 100% rename from spec-kit-extensions/canvas-design/schemas/page.schema.json rename to spec-kit-extensions/extension-canvas-design/schemas/page.schema.json diff --git a/spec-kit-extensions/tests/requirements.txt b/spec-kit-extensions/tests/requirements.txt deleted file mode 100644 index a6e9bc45..00000000 --- a/spec-kit-extensions/tests/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 0a5854e9..00000000 --- a/spec-kit-extensions/tests/test_canvas_design.py +++ /dev/null @@ -1,366 +0,0 @@ -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()