From f661664d9012172a537b15412cfccec82dde92cb Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 13:41:10 -0500 Subject: [PATCH 1/7] Add preset-style manual extension release trigger Reuse the extension publisher directly so GITHUB_TOKEN tag creation does not rely on triggering another workflow. Preserve existing packaging and gh release create behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 28 +++++++++ .github/workflows/release-extension.yml | 47 +++++++++++++- spec-kit-extensions/README.md | 28 ++++++++- .../tests/test_canvas_design.py | 63 +++++++++++++++---- 4 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/release-extension-trigger.yml diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml new file mode 100644 index 00000000..2371df6d --- /dev/null +++ b/.github/workflows/release-extension-trigger.yml @@ -0,0 +1,28 @@ +name: Release Extension Trigger + +on: + workflow_dispatch: + inputs: + extension_id: + description: 'Extension to release' + required: true + type: choice + options: + - canvas-design + 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..353ec81c 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -1,16 +1,26 @@ 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*' permissions: @@ -19,6 +29,8 @@ permissions: jobs: package: runs-on: ubuntu-latest + outputs: + tag: ${{ steps.validate.outputs.tag }} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,6 +43,10 @@ jobs: run: python -m pip install -r spec-kit-extensions/tests/requirements.txt - name: Validate release version + id: validate + env: + EXTENSION_ID: ${{ inputs.extension_id }} + VERSION: ${{ inputs.version }} run: | python - <<'PY' import json @@ -42,6 +58,12 @@ jobs: 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}") + if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch": + if os.environ["EXTENSION_ID"] != "canvas-design": + raise SystemExit("Only canvas-design releases are supported") + requested_version = os.environ["VERSION"].removeprefix("v") + if requested_version != version: + raise SystemExit("Requested version must match extension.yml version") tag = f"extension/canvas-design/v{version}" with open("spec-kit-extensions/catalog.json") as source: entry = json.load(source)["extensions"]["canvas-design"] @@ -53,6 +75,8 @@ jobs: 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") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"tag={tag}\n") PY - name: Test package contracts @@ -87,7 +111,7 @@ jobs: 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/canvas-design/v') runs-on: ubuntu-latest permissions: contents: write @@ -97,16 +121,35 @@ jobs: 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: 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 }} run: | - TAG="${GITHUB_REF#refs/tags/}" gh release create "$TAG" canvas-design.zip \ --verify-tag \ --latest=false \ diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index 9450f963..97e6b83b 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -28,5 +28,29 @@ 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. -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 Canvas Design 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**, select **canvas-design** for + **Extension to release**, and enter the 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 directly using +`GITHUB_TOKEN`; no personal access token is needed. It validates the requested +extension/version, manifest, catalog version and download URL, tests the package +and ZIP, then creates `extension/canvas-design/vX.Y.Z` at the selected commit and +publishes `canvas-design.zip`. Only Canvas Design is currently supported. + +Direct pushes of `extension/canvas-design/vX.Y.Z` tags still run the same +publisher. Pull requests and relevant pushes to `main` only validate and package; +they do not create tags or releases. + +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/tests/test_canvas_design.py b/spec-kit-extensions/tests/test_canvas_design.py index 0a5854e9..99481289 100644 --- a/spec-kit-extensions/tests/test_canvas_design.py +++ b/spec-kit-extensions/tests/test_canvas_design.py @@ -283,44 +283,80 @@ 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), + ("pull_request", "refs/pull/1/merge", "", "", True), + ("push", "refs/heads/main", "", "", True), + ("push", tag, "", "", True), + ("push", "refs/tags/extension/canvas-design/v999.0.0", "", "", False), + ("push", "refs/tags/canvas-design-v0.1.0", "", "", False), + ("workflow_dispatch", "refs/heads/main", "canvas-design", version, True), + ("workflow_dispatch", "refs/heads/main", "canvas-design", f"v{version}", True), + ("workflow_dispatch", "refs/heads/main", "canvas-design", "999.0.0", False), + ("workflow_dispatch", "refs/heads/main", "canvas-design", "", False), + ("workflow_dispatch", "refs/heads/main", "canvas-design", f"{version}; echo bad", False), + ("workflow_dispatch", "refs/heads/main", "unsupported", version, False), + ("workflow_dispatch", "refs/heads/main", "../canvas-design", version, False), ] - for ref, succeeds in cases: - with self.subTest(ref=ref): - env = dict(os.environ, GITHUB_REF=ref) + for event, ref, extension_id, requested_version, succeeds in cases: + with self.subTest(event=event, ref=ref, extension_id=extension_id, + version=requested_version), tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "output" + env = dict( + os.environ, GITHUB_REF=ref, GITHUB_EVENT_NAME=event, + EXTENSION_ID=extension_id, VERSION=requested_version, + GITHUB_OUTPUT=str(output), + ) 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) + if succeeds: + self.assertEqual(output.read_text(), f"tag={tag.removeprefix('refs/tags/')}\n") + else: + self.assertFalse(output.exists()) def test_release_triggers_and_permissions(self): triggers = self.workflow["on"] - self.assertEqual(set(triggers), {"pull_request", "push"}) + self.assertEqual(set(triggers), {"workflow_call", "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"}) + self.assertEqual(triggers["workflow_call"]["inputs"], { + "extension_id": {"required": "true", "type": "string"}, + "version": {"required": "true", "type": "string"}, + }) + trigger = yaml.load( + (EXTENSIONS.parent / ".github/workflows/release-extension-trigger.yml").read_text("utf-8"), + Loader=yaml.BaseLoader, + ) + self.assertEqual(set(trigger["on"]), {"workflow_dispatch"}) + inputs = trigger["on"]["workflow_dispatch"]["inputs"] + self.assertEqual(inputs["extension_id"]["options"], ["canvas-design"]) + self.assertEqual(inputs["version"]["required"], "true") + caller = trigger["jobs"]["tag-and-release"] + self.assertEqual(caller["uses"], "./.github/workflows/release-extension.yml") + self.assertEqual(caller["permissions"], {"contents": "write"}) + self.assertEqual(caller["with"], { + "extension_id": "${{ inputs.extension_id }}", + "version": "${{ inputs.version }}", + }) + for event in ("pull_request", "push"): + self.assertIn(".github/workflows/release-extension-trigger.yml", triggers[event]["paths"]) release = self.workflow["jobs"]["release"] self.assertEqual(release["needs"], "package") self.assertEqual(release["permissions"], {"contents": "write"}) self.assertEqual( release["if"], + "github.event_name == 'workflow_dispatch' || " "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"] @@ -341,7 +377,8 @@ def test_release_rejects_catalog_drift(self): 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}"), + env=dict(os.environ, GITHUB_EVENT_NAME="push", + GITHUB_REF=f"refs/tags/extension/canvas-design/v{version}"), capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) From dd7e9ba45937300052f1614582d743cb63c63936 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 13:47:12 -0500 Subject: [PATCH 2/7] Generalize release workflow to repository extensions Use the requested extension ID for manifest and catalog validation, packaging, tags and release metadata. Validate all extension packages on PR and main runs, and clarify built-in token behavior in release instructions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 6 +- .github/workflows/release-extension.yml | 126 +++++++++++++----- spec-kit-extensions/README.md | 32 +++-- .../tests/test_canvas_design.py | 84 +++++++++++- 4 files changed, 190 insertions(+), 58 deletions(-) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 2371df6d..48a8a6ae 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -4,11 +4,9 @@ on: workflow_dispatch: inputs: extension_id: - description: 'Extension to release' + description: 'Extension directory name under spec-kit-extensions/ (e.g., canvas-design)' required: true - type: choice - options: - - canvas-design + type: string version: description: 'Version to release (e.g., 0.1.0; optional v prefix)' required: true diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 353ec81c..804e26d6 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -22,7 +22,7 @@ on: - '.github/workflows/release-extension.yml' - '.github/workflows/release-extension-trigger.yml' tags: - - 'extension/canvas-design/v*' + - 'extension/*/v*' permissions: contents: read @@ -31,6 +31,8 @@ jobs: 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 @@ -52,52 +54,106 @@ jobs: 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}") + root = Path("spec-kit-extensions") + ref = os.environ["GITHUB_REF"] + selected_id = "" + requested_version = None if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch": - if os.environ["EXTENSION_ID"] != "canvas-design": - raise SystemExit("Only canvas-design releases are supported") + selected_id = os.environ["EXTENSION_ID"] + if not selected_id: + raise SystemExit("Extension ID is required") requested_version = os.environ["VERSION"].removeprefix("v") - if requested_version != version: + elif ref.startswith("refs/tags/"): + match = re.fullmatch(r"refs/tags/extension/([^/]+)/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"[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") - 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}": + 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/{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: - output.write(f"tag={tag}\n") + 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' + import json + import os 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()) + for extension_id in json.loads(os.environ["EXTENSION_IDS"]): + package = Path("spec-kit-extensions") / extension_id + files = {} + with ZipFile(f"{extension_id}.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(): + name = path.relative_to(package).as_posix() + files[name] = path + 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 + if: steps.validate.outputs.extension_id == '' || steps.validate.outputs.extension_id == 'canvas-design' env: CANVAS_DESIGN_ARCHIVE: canvas-design.zip run: python -m unittest discover -s spec-kit-extensions/tests -v @@ -105,18 +161,18 @@ jobs: - 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: github.event_name == 'workflow_dispatch' || 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 @@ -126,7 +182,7 @@ jobs: - uses: actions/download-artifact@v4 with: - name: canvas-design-package + name: extension-packages - name: Create or verify release tag env: @@ -149,9 +205,11 @@ jobs: 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: | - 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_NAME ${TAG##*/}" \ + --notes "Specify CLI extension: $EXTENSION_NAME. See spec-kit-extensions/$EXTENSION_ID/README.md for installation and requirements." diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index 97e6b83b..c5b25634 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -28,24 +28,30 @@ 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. -To publish Canvas Design from the GitHub Actions UI after merging those updates: +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**, select **canvas-design** for - **Extension to release**, and enter the manifest version (for example, - `0.1.0`; an optional `v` prefix is accepted). +3. Leave **Use workflow from** set to **main**, enter the extension's directory + name under `spec-kit-extensions/` (for example, `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 directly using -`GITHUB_TOKEN`; no personal access token is needed. It validates the requested -extension/version, manifest, catalog version and download URL, tests the package -and ZIP, then creates `extension/canvas-design/vX.Y.Z` at the selected commit and -publishes `canvas-design.zip`. Only Canvas Design is currently supported. - -Direct pushes of `extension/canvas-design/vX.Y.Z` tags still run the same -publisher. Pull requests and relevant pushes to `main` only validate and package; -they do not create tags or releases. +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, tests the package +and ZIP, then creates `extension//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. + +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. 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 diff --git a/spec-kit-extensions/tests/test_canvas_design.py b/spec-kit-extensions/tests/test_canvas_design.py index 99481289..a2c7d29b 100644 --- a/spec-kit-extensions/tests/test_canvas_design.py +++ b/spec-kit-extensions/tests/test_canvas_design.py @@ -97,7 +97,7 @@ def test_copilot_catalog_matches_package(self): "spec-kit-extensions/catalog.json" ) self.assertEqual(self.catalog["catalog_url"], catalog_url) - self.assertEqual(set(self.catalog["extensions"]), {"canvas-design"}) + self.assertIn("canvas-design", self.catalog["extensions"]) entry = self.catalog["extensions"]["canvas-design"] for field in ("id", "name", "version", "author", "repository", "license"): with self.subTest(field=field): @@ -274,7 +274,8 @@ def test_inline_workflow_packaging(self): 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, + cwd=root, env=dict(os.environ, EXTENSION_IDS='["canvas-design"]'), + capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assert_archive_matches_package(root / "canvas-design.zip") @@ -294,6 +295,7 @@ def test_release_version_guards(self): ("workflow_dispatch", "refs/heads/main", "canvas-design", "", False), ("workflow_dispatch", "refs/heads/main", "canvas-design", f"{version}; echo bad", False), ("workflow_dispatch", "refs/heads/main", "unsupported", version, False), + ("workflow_dispatch", "refs/heads/main", "", version, False), ("workflow_dispatch", "refs/heads/main", "../canvas-design", version, False), ] for event, ref, extension_id, requested_version, succeeds in cases: @@ -312,14 +314,81 @@ def test_release_version_guards(self): self.assertEqual(result.returncode == 0, succeeds, result.stdout + result.stderr) if succeeds: - self.assertEqual(output.read_text(), f"tag={tag.removeprefix('refs/tags/')}\n") + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(json.loads(values["extension_ids"]), ["canvas-design"]) + if event == "workflow_dispatch" or ref.startswith("refs/tags/"): + self.assertEqual(values["tag"], tag.removeprefix("refs/tags/")) + self.assertEqual(values["extension_id"], "canvas-design") + self.assertEqual(values["extension_name"], self.manifest["extension"]["name"]) + else: + self.assertNotIn("tag", values) else: self.assertFalse(output.exists()) + def test_release_supports_another_extension(self): + with tempfile.TemporaryDirectory(prefix="extension-release-") as temporary: + root = Path(temporary) + extensions = root / "spec-kit-extensions" + shutil.copytree(PACKAGE, extensions / "canvas-design") + package = extensions / "sample-extension" + package.mkdir() + manifest = copy.deepcopy(self.manifest) + manifest["extension"].update(id="sample-extension", name="Sample Extension", version="1.2.3") + (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") + (package / "README.md").write_text("Sample extension", "utf-8") + catalog = copy.deepcopy(self.catalog) + entry = copy.deepcopy(catalog["extensions"]["canvas-design"]) + tag = "extension/sample-extension/v1.2.3" + entry.update( + id="sample-extension", name="Sample Extension", version="1.2.3", + download_url=f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/sample-extension.zip", + ) + catalog["extensions"]["sample-extension"] = entry + (extensions / "catalog.json").write_text(json.dumps(catalog), "utf-8") + cases = [ + ("workflow_dispatch", "refs/heads/main", ["sample-extension"]), + ("push", f"refs/tags/{tag}", ["sample-extension"]), + ("push", "refs/heads/main", ["canvas-design", "sample-extension"]), + ("pull_request", "refs/pull/1/merge", ["canvas-design", "sample-extension"]), + ] + for event, ref, expected_ids in cases: + with self.subTest(event=event, ref=ref): + output = root / "output" + output.write_text("") + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Validate release version")], + cwd=root, capture_output=True, text=True, + env=dict(os.environ, GITHUB_EVENT_NAME=event, GITHUB_REF=ref, + EXTENSION_ID="sample-extension", VERSION="1.2.3", + GITHUB_OUTPUT=str(output)), + ) + self.assertEqual(result.returncode, 0, result.stderr) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(json.loads(values["extension_ids"]), expected_ids) + if len(expected_ids) == 1: + self.assertEqual(values["tag"], tag) + self.assertEqual(values["extension_id"], "sample-extension") + self.assertEqual(values["extension_name"], "Sample Extension") + else: + self.assertNotIn("tag", values) + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Create extension ZIP")], + cwd=root, capture_output=True, text=True, + env=dict(os.environ, EXTENSION_IDS=values["extension_ids"]), + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual({path.stem for path in root.glob("*.zip")}, set(expected_ids)) + with ZipFile(root / "sample-extension.zip") as archive: + self.assertEqual(set(archive.namelist()), {"extension.yml", "README.md"}) + for name in archive.namelist(): + self.assertEqual(archive.read(name), (package / name).read_bytes()) + for path in root.glob("*.zip"): + path.unlink() + def test_release_triggers_and_permissions(self): triggers = self.workflow["on"] self.assertEqual(set(triggers), {"workflow_call", "pull_request", "push"}) - self.assertEqual(triggers["push"]["tags"], ["extension/canvas-design/v*"]) + self.assertEqual(triggers["push"]["tags"], ["extension/*/v*"]) self.assertEqual(triggers["pull_request"]["branches"], ["main"]) self.assertEqual(self.workflow["permissions"], {"contents": "read"}) self.assertEqual(triggers["workflow_call"]["inputs"], { @@ -332,7 +401,8 @@ def test_release_triggers_and_permissions(self): ) self.assertEqual(set(trigger["on"]), {"workflow_dispatch"}) inputs = trigger["on"]["workflow_dispatch"]["inputs"] - self.assertEqual(inputs["extension_id"]["options"], ["canvas-design"]) + self.assertEqual(inputs["extension_id"]["type"], "string") + self.assertEqual(inputs["extension_id"]["required"], "true") self.assertEqual(inputs["version"]["required"], "true") caller = trigger["jobs"]["tag-and-release"] self.assertEqual(caller["uses"], "./.github/workflows/release-extension.yml") @@ -349,13 +419,13 @@ def test_release_triggers_and_permissions(self): self.assertEqual( release["if"], "github.event_name == 'workflow_dispatch' || " - "startsWith(github.ref, 'refs/tags/extension/canvas-design/v')", + "startsWith(github.ref, 'refs/tags/extension/')", ) publish = next( step for step in release["steps"] if step.get("name") == "Publish validated extension" )["run"] - self.assertIn('gh release create "$TAG" canvas-design.zip', publish) + self.assertIn('gh release create "$TAG" "$EXTENSION_ID.zip"', publish) self.assertIn("--verify-tag", publish) def test_release_rejects_catalog_drift(self): From 06bbf866e2aac9ad43163733574194a20e613ca9 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 14:02:37 -0500 Subject: [PATCH 3/7] Validate all declared extension assets before packaging Reject missing or non-file command, template, and configuration assets before ZIP creation. Supply declared assets in the generic extension fixture and add missing, directory, escaping-path, and invalid-value regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-extension.yml | 30 +++++++++---- spec-kit-extensions/README.md | 4 ++ .../tests/test_canvas_design.py | 44 ++++++++++++++++++- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 804e26d6..87e21f32 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -130,20 +130,34 @@ jobs: python - <<'PY' import json import os - from pathlib import Path + from pathlib import Path, PurePosixPath from zipfile import ZIP_DEFLATED, ZipFile + import yaml 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(): + 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 path in sorted(package.rglob("*")): - if path.is_symlink(): - raise SystemExit(f"Cannot package symlink: {path}") - if path.is_file(): - name = path.relative_to(package).as_posix() - files[name] = path - archive.write(path, name) + 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}") diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index c5b25634..7128605d 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -48,6 +48,10 @@ 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 diff --git a/spec-kit-extensions/tests/test_canvas_design.py b/spec-kit-extensions/tests/test_canvas_design.py index a2c7d29b..4a20a7cf 100644 --- a/spec-kit-extensions/tests/test_canvas_design.py +++ b/spec-kit-extensions/tests/test_canvas_design.py @@ -331,11 +331,15 @@ def test_release_supports_another_extension(self): extensions = root / "spec-kit-extensions" shutil.copytree(PACKAGE, extensions / "canvas-design") package = extensions / "sample-extension" - package.mkdir() + shutil.copytree(PACKAGE, package) manifest = copy.deepcopy(self.manifest) manifest["extension"].update(id="sample-extension", name="Sample Extension", version="1.2.3") + manifest["provides"]["config"] = [ + {"name": "sample-config.yml", "template": "config-template.yml"}, + ] (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") (package / "README.md").write_text("Sample extension", "utf-8") + (package / "config-template.yml").write_text("enabled: true\n", "utf-8") catalog = copy.deepcopy(self.catalog) entry = copy.deepcopy(catalog["extensions"]["canvas-design"]) tag = "extension/sample-extension/v1.2.3" @@ -379,12 +383,48 @@ def test_release_supports_another_extension(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual({path.stem for path in root.glob("*.zip")}, set(expected_ids)) with ZipFile(root / "sample-extension.zip") as archive: - self.assertEqual(set(archive.namelist()), {"extension.yml", "README.md"}) + self.assertEqual(set(archive.namelist()), FILES | {"config-template.yml"}) for name in archive.namelist(): self.assertEqual(archive.read(name), (package / name).read_bytes()) for path in root.glob("*.zip"): path.unlink() + def test_packaging_rejects_invalid_declared_files(self): + cases = [ + ("commands", "file", "commands/load-page.md", "missing"), + ("templates", "file", "pages/setup.json", "missing"), + ("config", "template", "config-template.yml", "missing"), + ("commands", "file", "commands/load-page.md", "directory"), + ("templates", "file", "../outside.json", "outside"), + ("commands", "file", None, "invalid"), + ] + for kind, field, name, problem in cases: + with self.subTest(kind=kind, name=name, problem=problem), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + package = root / "spec-kit-extensions/sample-extension" + shutil.copytree(PACKAGE, package) + manifest = copy.deepcopy(self.manifest) + manifest["extension"]["id"] = "sample-extension" + manifest["provides"] = {kind: [{"name": "sample", field: name}]} + (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") + if problem in ("missing", "directory"): + path = package / name + if path.is_file(): + path.unlink() + if problem == "directory": + path.mkdir() + elif problem == "outside": + (package / name).write_text("{}", "utf-8") + result = subprocess.run( + [sys.executable, "-c", self.workflow_python("Create extension ZIP")], + cwd=root, env=dict(os.environ, EXTENSION_IDS='["sample-extension"]'), + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"Declared {kind} {field} is not a regular package file", result.stderr) + self.assertIn(repr(name), result.stderr) + self.assertFalse((root / "sample-extension.zip").exists()) + def test_release_triggers_and_permissions(self): triggers = self.workflow["on"] self.assertEqual(set(triggers), {"workflow_call", "pull_request", "push"}) From 9c6358944faa127f75db426d6e4fe29e80c1fccc Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 16:45:44 -0500 Subject: [PATCH 4/7] Align extension package identity and release naming Rename Canvas Design to extension-canvas-design without a version bump. Use extension--vX.Y.Z tags and package-ID release titles, exclude extension tags from preset publication, and update catalog, commands, docs, and tests while preserving discovery and provider identities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 2 +- .github/workflows/release-extension.yml | 16 +-- .github/workflows/release-preset.yml | 1 + AGENTS.md | 6 + spec-kit-extensions/README.md | 19 ++- spec-kit-extensions/catalog.json | 8 +- .../README.md | 12 +- .../commands/load-page.md | 4 +- .../extension.yml | 4 +- .../pages/appearance.json | 0 .../pages/artifacts.json | 0 .../pages/results.json | 0 .../pages/setup.json | 0 .../schemas/page.schema.json | 0 ...ign.py => test_extension_canvas_design.py} | 115 ++++++++++-------- 15 files changed, 110 insertions(+), 77 deletions(-) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/README.md (94%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/commands/load-page.md (94%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/extension.yml (92%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/pages/appearance.json (100%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/pages/artifacts.json (100%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/pages/results.json (100%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/pages/setup.json (100%) rename spec-kit-extensions/{canvas-design => extension-canvas-design}/schemas/page.schema.json (100%) rename spec-kit-extensions/tests/{test_canvas_design.py => test_extension_canvas_design.py} (84%) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 48a8a6ae..6b7882d5 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: extension_id: - description: 'Extension directory name under spec-kit-extensions/ (e.g., canvas-design)' + description: 'Extension directory name under spec-kit-extensions/ (e.g., extension-canvas-design)' required: true type: string version: diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 87e21f32..4db57480 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -22,7 +22,7 @@ on: - '.github/workflows/release-extension.yml' - '.github/workflows/release-extension-trigger.yml' tags: - - 'extension/*/v*' + - 'extension-*-v*' permissions: contents: read @@ -67,7 +67,7 @@ jobs: 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/([^/]+)/v([0-9]+\.[0-9]+\.[0-9]+)", ref) + 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() @@ -80,7 +80,7 @@ jobs: catalog = json.load(source)["extensions"] outputs = {"extension_ids": json.dumps(extension_ids)} for extension_id in extension_ids: - if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", extension_id): + 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(): @@ -104,7 +104,7 @@ jobs: 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/{extension_id}/v{version}" + 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") @@ -167,9 +167,9 @@ jobs: PY - name: Verify release archive - if: steps.validate.outputs.extension_id == '' || steps.validate.outputs.extension_id == 'canvas-design' + if: steps.validate.outputs.extension_id == '' || steps.validate.outputs.extension_id == 'extension-canvas-design' env: - CANVAS_DESIGN_ARCHIVE: canvas-design.zip + CANVAS_DESIGN_ARCHIVE: extension-canvas-design.zip run: python -m unittest discover -s spec-kit-extensions/tests -v - name: Upload validated archive @@ -181,7 +181,7 @@ jobs: release: needs: package - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/extension/') + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/extension-') runs-on: ubuntu-latest permissions: contents: write @@ -225,5 +225,5 @@ jobs: gh release create "$TAG" "$EXTENSION_ID.zip" \ --verify-tag \ --latest=false \ - --title "$EXTENSION_NAME ${TAG##*/}" \ + --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/README.md b/spec-kit-extensions/README.md index 7128605d..cbed4891 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,13 +27,20 @@ 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. 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, `canvas-design`), and enter + 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. @@ -43,7 +50,7 @@ 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, tests the package -and ZIP, then creates `extension//vX.Y.Z` at the selected commit +and 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. @@ -52,10 +59,12 @@ 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 +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 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/canvas-design/README.md b/spec-kit-extensions/extension-canvas-design/README.md similarity index 94% rename from spec-kit-extensions/canvas-design/README.md rename to spec-kit-extensions/extension-canvas-design/README.md index 8a5226d8..e3062c27 100644 --- a/spec-kit-extensions/canvas-design/README.md +++ b/spec-kit-extensions/extension-canvas-design/README.md @@ -11,14 +11,14 @@ 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 ``` 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 +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 release ZIP must be published before either installation method can succeed. @@ -26,7 +26,7 @@ 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 +`speckit-extension-canvas-design-load-page`. Use `/skills reload` to discover newly installed or composed skills in the current session. ## Required Designer capability @@ -97,7 +97,7 @@ preset: description: Replace the Designer Appearance page. requires: speckit_version: ">=1.0.7" - extensions: [canvas-design] + extensions: [extension-canvas-design] provides: templates: - type: template @@ -122,7 +122,7 @@ preset: description: Add a Designer Accessibility page. requires: speckit_version: ">=1.0.7" - extensions: [canvas-design] + extensions: [extension-canvas-design] provides: templates: - type: template @@ -130,7 +130,7 @@ provides: file: pages/accessibility.json strategy: replace - type: command - name: speckit.canvas-design.load-page + name: speckit.extension-canvas-design.load-page file: commands/add-pages.md strategy: append ``` 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/test_canvas_design.py b/spec-kit-extensions/tests/test_extension_canvas_design.py similarity index 84% rename from spec-kit-extensions/tests/test_canvas_design.py rename to spec-kit-extensions/tests/test_extension_canvas_design.py index 4a20a7cf..9731327e 100644 --- a/spec-kit-extensions/tests/test_canvas_design.py +++ b/spec-kit-extensions/tests/test_extension_canvas_design.py @@ -15,7 +15,7 @@ EXTENSIONS = Path(__file__).resolve().parents[1] -PACKAGE = EXTENSIONS / "canvas-design" +PACKAGE = EXTENSIONS / "extension-canvas-design" PAGE_NAMES = ("setup", "artifacts", "appearance", "results") FILES = { "extension.yml", @@ -56,7 +56,7 @@ def workflow_python(self, step_name): 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.assertEqual(extension["id"], "extension-canvas-design") self.assertRegex(extension["version"], r"^\d+\.\d+\.\d+$") self.assertIn( f'Canvas Design **{extension["version"]}**', @@ -69,7 +69,7 @@ def test_manifest_and_shipped_files(self): self.assertEqual( [(command["name"], command["file"]) for command in self.manifest["provides"]["commands"]], - [("speckit.canvas-design.load-page", "commands/load-page.md")], + [("speckit.extension-canvas-design.load-page", "commands/load-page.md")], ) self.assertEqual( [(template["name"], template["file"]) @@ -97,8 +97,8 @@ def test_copilot_catalog_matches_package(self): "spec-kit-extensions/catalog.json" ) self.assertEqual(self.catalog["catalog_url"], catalog_url) - self.assertIn("canvas-design", self.catalog["extensions"]) - entry = self.catalog["extensions"]["canvas-design"] + self.assertIn("extension-canvas-design", self.catalog["extensions"]) + entry = self.catalog["extensions"]["extension-canvas-design"] for field in ("id", "name", "version", "author", "repository", "license"): with self.subTest(field=field): self.assertEqual(entry[field], self.manifest["extension"][field]) @@ -115,12 +115,12 @@ def test_copilot_catalog_matches_package(self): self.assertEqual( entry["download_url"], "https://github.com/github/spec-kit-copilot/releases/download/" - f"extension/canvas-design/v{version}/canvas-design.zip", + f"extension-canvas-design-v{version}/extension-canvas-design.zip", ) self.assertEqual( entry["documentation"], "https://github.com/github/spec-kit-copilot/blob/main/" - "spec-kit-extensions/canvas-design/README.md", + "spec-kit-extensions/extension-canvas-design/README.md", ) for path in (EXTENSIONS / "README.md", PACKAGE / "README.md"): with self.subTest(readme=path): @@ -129,7 +129,7 @@ def test_copilot_catalog_matches_package(self): f"specify extension catalog add {catalog_url} " "--name spec-kit-copilot --install-allowed", readme, ) - self.assertIn("specify extension add canvas-design\n", readme) + self.assertIn("specify extension add extension-canvas-design\n", readme) def test_schema_and_default_pages(self): Draft202012Validator.check_schema(self.schema) @@ -254,9 +254,9 @@ 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 " + "specify extension add extension-canvas-design --from " "https://github.com/github/spec-kit-copilot/releases/download/" - f"extension/canvas-design/v{version}/canvas-design.zip", + f"extension-canvas-design-v{version}/extension-canvas-design.zip", readme, ) self.assertIn('--integration copilot --integration-options="--skills"', readme) @@ -269,34 +269,38 @@ 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: + with tempfile.TemporaryDirectory(prefix="extension-canvas-design-package-") as temporary: root = Path(temporary) - shutil.copytree(PACKAGE, root / "spec-kit-extensions/canvas-design") + shutil.copytree(PACKAGE, root / "spec-kit-extensions/extension-canvas-design") result = subprocess.run( [sys.executable, "-c", self.workflow_python("Create extension ZIP")], - cwd=root, env=dict(os.environ, EXTENSION_IDS='["canvas-design"]'), + cwd=root, env=dict(os.environ, EXTENSION_IDS='["extension-canvas-design"]'), capture_output=True, text=True, ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assert_archive_matches_package(root / "canvas-design.zip") + self.assert_archive_matches_package(root / "extension-canvas-design.zip") def test_release_version_guards(self): version = self.manifest["extension"]["version"] - tag = f"refs/tags/extension/canvas-design/v{version}" + tag = f"refs/tags/extension-canvas-design-v{version}" cases = [ ("pull_request", "refs/pull/1/merge", "", "", True), ("push", "refs/heads/main", "", "", True), ("push", tag, "", "", True), - ("push", "refs/tags/extension/canvas-design/v999.0.0", "", "", False), + ("push", "refs/tags/extension-canvas-design-v999.0.0", "", "", False), ("push", "refs/tags/canvas-design-v0.1.0", "", "", False), - ("workflow_dispatch", "refs/heads/main", "canvas-design", version, True), - ("workflow_dispatch", "refs/heads/main", "canvas-design", f"v{version}", True), - ("workflow_dispatch", "refs/heads/main", "canvas-design", "999.0.0", False), - ("workflow_dispatch", "refs/heads/main", "canvas-design", "", False), - ("workflow_dispatch", "refs/heads/main", "canvas-design", f"{version}; echo bad", False), + ("push", "refs/tags/extension/canvas-design/v0.1.0", "", "", False), + ("push", "refs/tags/copilot-sub-agents-v1.0.0", "", "", False), + ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", version, True), + ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", f"v{version}", True), + ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", "999.0.0", False), + ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", "", False), + ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", f"{version}; echo bad", False), ("workflow_dispatch", "refs/heads/main", "unsupported", version, False), + ("workflow_dispatch", "refs/heads/main", "extension-missing", version, False), + ("workflow_dispatch", "refs/heads/main", "canvas-design", version, False), ("workflow_dispatch", "refs/heads/main", "", version, False), - ("workflow_dispatch", "refs/heads/main", "../canvas-design", version, False), + ("workflow_dispatch", "refs/heads/main", "../extension-canvas-design", version, False), ] for event, ref, extension_id, requested_version, succeeds in cases: with self.subTest(event=event, ref=ref, extension_id=extension_id, @@ -315,10 +319,10 @@ def test_release_version_guards(self): result.stdout + result.stderr) if succeeds: values = dict(line.split("=", 1) for line in output.read_text().splitlines()) - self.assertEqual(json.loads(values["extension_ids"]), ["canvas-design"]) + self.assertEqual(json.loads(values["extension_ids"]), ["extension-canvas-design"]) if event == "workflow_dispatch" or ref.startswith("refs/tags/"): self.assertEqual(values["tag"], tag.removeprefix("refs/tags/")) - self.assertEqual(values["extension_id"], "canvas-design") + self.assertEqual(values["extension_id"], "extension-canvas-design") self.assertEqual(values["extension_name"], self.manifest["extension"]["name"]) else: self.assertNotIn("tag", values) @@ -329,11 +333,11 @@ def test_release_supports_another_extension(self): with tempfile.TemporaryDirectory(prefix="extension-release-") as temporary: root = Path(temporary) extensions = root / "spec-kit-extensions" - shutil.copytree(PACKAGE, extensions / "canvas-design") - package = extensions / "sample-extension" + shutil.copytree(PACKAGE, extensions / "extension-canvas-design") + package = extensions / "extension-sample" shutil.copytree(PACKAGE, package) manifest = copy.deepcopy(self.manifest) - manifest["extension"].update(id="sample-extension", name="Sample Extension", version="1.2.3") + manifest["extension"].update(id="extension-sample", name="Sample Extension", version="1.2.3") manifest["provides"]["config"] = [ {"name": "sample-config.yml", "template": "config-template.yml"}, ] @@ -341,19 +345,19 @@ def test_release_supports_another_extension(self): (package / "README.md").write_text("Sample extension", "utf-8") (package / "config-template.yml").write_text("enabled: true\n", "utf-8") catalog = copy.deepcopy(self.catalog) - entry = copy.deepcopy(catalog["extensions"]["canvas-design"]) - tag = "extension/sample-extension/v1.2.3" + entry = copy.deepcopy(catalog["extensions"]["extension-canvas-design"]) + tag = "extension-sample-v1.2.3" entry.update( - id="sample-extension", name="Sample Extension", version="1.2.3", - download_url=f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/sample-extension.zip", + id="extension-sample", name="Sample Extension", version="1.2.3", + download_url=f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/extension-sample.zip", ) - catalog["extensions"]["sample-extension"] = entry + catalog["extensions"]["extension-sample"] = entry (extensions / "catalog.json").write_text(json.dumps(catalog), "utf-8") cases = [ - ("workflow_dispatch", "refs/heads/main", ["sample-extension"]), - ("push", f"refs/tags/{tag}", ["sample-extension"]), - ("push", "refs/heads/main", ["canvas-design", "sample-extension"]), - ("pull_request", "refs/pull/1/merge", ["canvas-design", "sample-extension"]), + ("workflow_dispatch", "refs/heads/main", ["extension-sample"]), + ("push", f"refs/tags/{tag}", ["extension-sample"]), + ("push", "refs/heads/main", ["extension-canvas-design", "extension-sample"]), + ("pull_request", "refs/pull/1/merge", ["extension-canvas-design", "extension-sample"]), ] for event, ref, expected_ids in cases: with self.subTest(event=event, ref=ref): @@ -363,7 +367,7 @@ def test_release_supports_another_extension(self): [sys.executable, "-c", self.workflow_python("Validate release version")], cwd=root, capture_output=True, text=True, env=dict(os.environ, GITHUB_EVENT_NAME=event, GITHUB_REF=ref, - EXTENSION_ID="sample-extension", VERSION="1.2.3", + EXTENSION_ID="extension-sample", VERSION="1.2.3", GITHUB_OUTPUT=str(output)), ) self.assertEqual(result.returncode, 0, result.stderr) @@ -371,7 +375,7 @@ def test_release_supports_another_extension(self): self.assertEqual(json.loads(values["extension_ids"]), expected_ids) if len(expected_ids) == 1: self.assertEqual(values["tag"], tag) - self.assertEqual(values["extension_id"], "sample-extension") + self.assertEqual(values["extension_id"], "extension-sample") self.assertEqual(values["extension_name"], "Sample Extension") else: self.assertNotIn("tag", values) @@ -382,7 +386,7 @@ def test_release_supports_another_extension(self): ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual({path.stem for path in root.glob("*.zip")}, set(expected_ids)) - with ZipFile(root / "sample-extension.zip") as archive: + with ZipFile(root / "extension-sample.zip") as archive: self.assertEqual(set(archive.namelist()), FILES | {"config-template.yml"}) for name in archive.namelist(): self.assertEqual(archive.read(name), (package / name).read_bytes()) @@ -401,10 +405,10 @@ def test_packaging_rejects_invalid_declared_files(self): for kind, field, name, problem in cases: with self.subTest(kind=kind, name=name, problem=problem), tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - package = root / "spec-kit-extensions/sample-extension" + package = root / "spec-kit-extensions/extension-sample" shutil.copytree(PACKAGE, package) manifest = copy.deepcopy(self.manifest) - manifest["extension"]["id"] = "sample-extension" + manifest["extension"]["id"] = "extension-sample" manifest["provides"] = {kind: [{"name": "sample", field: name}]} (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") if problem in ("missing", "directory"): @@ -417,18 +421,18 @@ def test_packaging_rejects_invalid_declared_files(self): (package / name).write_text("{}", "utf-8") result = subprocess.run( [sys.executable, "-c", self.workflow_python("Create extension ZIP")], - cwd=root, env=dict(os.environ, EXTENSION_IDS='["sample-extension"]'), + cwd=root, env=dict(os.environ, EXTENSION_IDS='["extension-sample"]'), capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) self.assertIn(f"Declared {kind} {field} is not a regular package file", result.stderr) self.assertIn(repr(name), result.stderr) - self.assertFalse((root / "sample-extension.zip").exists()) + self.assertFalse((root / "extension-sample.zip").exists()) def test_release_triggers_and_permissions(self): triggers = self.workflow["on"] self.assertEqual(set(triggers), {"workflow_call", "pull_request", "push"}) - self.assertEqual(triggers["push"]["tags"], ["extension/*/v*"]) + self.assertEqual(triggers["push"]["tags"], ["extension-*-v*"]) self.assertEqual(triggers["pull_request"]["branches"], ["main"]) self.assertEqual(self.workflow["permissions"], {"contents": "read"}) self.assertEqual(triggers["workflow_call"]["inputs"], { @@ -459,14 +463,27 @@ def test_release_triggers_and_permissions(self): self.assertEqual( release["if"], "github.event_name == 'workflow_dispatch' || " - "startsWith(github.ref, 'refs/tags/extension/')", + "startsWith(github.ref, 'refs/tags/extension-')", ) publish = next( step for step in release["steps"] if step.get("name") == "Publish validated extension" )["run"] self.assertIn('gh release create "$TAG" "$EXTENSION_ID.zip"', publish) + self.assertIn('--title "$EXTENSION_ID ${TAG##*-}"', publish) self.assertIn("--verify-tag", publish) + preset_workflow = yaml.load( + (EXTENSIONS.parent / ".github/workflows/release-preset.yml").read_text("utf-8"), + Loader=yaml.BaseLoader, + ) + self.assertEqual(preset_workflow["on"]["push"]["tags"], [ + "*-v[0-9]+.[0-9]+.[0-9]+", "!extension-*", + ]) + verify = next( + step for step in self.workflow["jobs"]["package"]["steps"] + if step.get("name") == "Verify release archive" + ) + self.assertEqual(verify["env"]["CANVAS_DESIGN_ARCHIVE"], "extension-canvas-design.zip") def test_release_rejects_catalog_drift(self): version = self.manifest["extension"]["version"] @@ -474,21 +491,21 @@ def test_release_rejects_catalog_drift(self): ("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: + with tempfile.TemporaryDirectory(prefix="extension-canvas-design-catalog-") as temporary: root = Path(temporary) - package = root / "spec-kit-extensions/canvas-design" + package = root / "spec-kit-extensions/extension-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 + catalog["extensions"]["extension-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_EVENT_NAME="push", - GITHUB_REF=f"refs/tags/extension/canvas-design/v{version}"), + GITHUB_REF=f"refs/tags/extension-canvas-design-v{version}"), capture_output=True, text=True, ) self.assertNotEqual(result.returncode, 0) From 1f4a483ff4d6d2e590fec6f4edf139052757936c Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 17:14:39 -0500 Subject: [PATCH 5/7] Align extension scaffolding with preset packages Remove the Canvas Design package test suite, test-only dependencies and workflow steps at user request. Retain README files following both preset packages and preserve generic manifest, declared-file and ZIP validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-extension.yml | 13 +- spec-kit-extensions/.gitignore | 1 - spec-kit-extensions/README.md | 4 +- .../extension-canvas-design/README.md | 21 - .../test_canvas_design.cpython-312.pyc | Bin 0 -> 38237 bytes ...st_extension_canvas_design.cpython-312.pyc | Bin 0 -> 39590 bytes spec-kit-extensions/tests/requirements.txt | 2 - .../tests/test_extension_canvas_design.py | 530 ------------------ 8 files changed, 4 insertions(+), 567 deletions(-) delete mode 100644 spec-kit-extensions/.gitignore create mode 100644 spec-kit-extensions/tests/__pycache__/test_canvas_design.cpython-312.pyc create mode 100644 spec-kit-extensions/tests/__pycache__/test_extension_canvas_design.cpython-312.pyc delete mode 100644 spec-kit-extensions/tests/requirements.txt delete mode 100644 spec-kit-extensions/tests/test_extension_canvas_design.py diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 4db57480..a34ccc51 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -41,8 +41,8 @@ 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 @@ -120,9 +120,6 @@ jobs: 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 }} @@ -166,12 +163,6 @@ jobs: raise SystemExit(f"Archive content mismatch: {extension_id}/{name}") PY - - name: Verify release archive - if: steps.validate.outputs.extension_id == '' || steps.validate.outputs.extension_id == 'extension-canvas-design' - env: - CANVAS_DESIGN_ARCHIVE: extension-canvas-design.zip - run: python -m unittest discover -s spec-kit-extensions/tests -v - - name: Upload validated archive uses: actions/upload-artifact@v4 with: 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 cbed4891..06abbf8c 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -49,8 +49,8 @@ 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, tests the package -and ZIP, then creates `-vX.Y.Z` at the selected commit +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. diff --git a/spec-kit-extensions/extension-canvas-design/README.md b/spec-kit-extensions/extension-canvas-design/README.md index e3062c27..ac68cf31 100644 --- a/spec-kit-extensions/extension-canvas-design/README.md +++ b/spec-kit-extensions/extension-canvas-design/README.md @@ -163,24 +163,3 @@ 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/tests/__pycache__/test_canvas_design.cpython-312.pyc b/spec-kit-extensions/tests/__pycache__/test_canvas_design.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0cd29becebc907119c283c2df8ebfc1a8c7b751 GIT binary patch literal 38237 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.0.2,<7 -jsonschema>=4.18,<5 diff --git a/spec-kit-extensions/tests/test_extension_canvas_design.py b/spec-kit-extensions/tests/test_extension_canvas_design.py deleted file mode 100644 index 9731327e..00000000 --- a/spec-kit-extensions/tests/test_extension_canvas_design.py +++ /dev/null @@ -1,530 +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 / "extension-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"], "extension-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.extension-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.assertIn("extension-canvas-design", self.catalog["extensions"]) - entry = self.catalog["extensions"]["extension-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}/extension-canvas-design.zip", - ) - self.assertEqual( - entry["documentation"], - "https://github.com/github/spec-kit-copilot/blob/main/" - "spec-kit-extensions/extension-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 extension-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 extension-canvas-design --from " - "https://github.com/github/spec-kit-copilot/releases/download/" - f"extension-canvas-design-v{version}/extension-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="extension-canvas-design-package-") as temporary: - root = Path(temporary) - shutil.copytree(PACKAGE, root / "spec-kit-extensions/extension-canvas-design") - result = subprocess.run( - [sys.executable, "-c", self.workflow_python("Create extension ZIP")], - cwd=root, env=dict(os.environ, EXTENSION_IDS='["extension-canvas-design"]'), - capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assert_archive_matches_package(root / "extension-canvas-design.zip") - - def test_release_version_guards(self): - version = self.manifest["extension"]["version"] - tag = f"refs/tags/extension-canvas-design-v{version}" - cases = [ - ("pull_request", "refs/pull/1/merge", "", "", True), - ("push", "refs/heads/main", "", "", True), - ("push", tag, "", "", True), - ("push", "refs/tags/extension-canvas-design-v999.0.0", "", "", False), - ("push", "refs/tags/canvas-design-v0.1.0", "", "", False), - ("push", "refs/tags/extension/canvas-design/v0.1.0", "", "", False), - ("push", "refs/tags/copilot-sub-agents-v1.0.0", "", "", False), - ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", version, True), - ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", f"v{version}", True), - ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", "999.0.0", False), - ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", "", False), - ("workflow_dispatch", "refs/heads/main", "extension-canvas-design", f"{version}; echo bad", False), - ("workflow_dispatch", "refs/heads/main", "unsupported", version, False), - ("workflow_dispatch", "refs/heads/main", "extension-missing", version, False), - ("workflow_dispatch", "refs/heads/main", "canvas-design", version, False), - ("workflow_dispatch", "refs/heads/main", "", version, False), - ("workflow_dispatch", "refs/heads/main", "../extension-canvas-design", version, False), - ] - for event, ref, extension_id, requested_version, succeeds in cases: - with self.subTest(event=event, ref=ref, extension_id=extension_id, - version=requested_version), tempfile.TemporaryDirectory() as temporary: - output = Path(temporary) / "output" - env = dict( - os.environ, GITHUB_REF=ref, GITHUB_EVENT_NAME=event, - EXTENSION_ID=extension_id, VERSION=requested_version, - GITHUB_OUTPUT=str(output), - ) - 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) - if succeeds: - values = dict(line.split("=", 1) for line in output.read_text().splitlines()) - self.assertEqual(json.loads(values["extension_ids"]), ["extension-canvas-design"]) - if event == "workflow_dispatch" or ref.startswith("refs/tags/"): - self.assertEqual(values["tag"], tag.removeprefix("refs/tags/")) - self.assertEqual(values["extension_id"], "extension-canvas-design") - self.assertEqual(values["extension_name"], self.manifest["extension"]["name"]) - else: - self.assertNotIn("tag", values) - else: - self.assertFalse(output.exists()) - - def test_release_supports_another_extension(self): - with tempfile.TemporaryDirectory(prefix="extension-release-") as temporary: - root = Path(temporary) - extensions = root / "spec-kit-extensions" - shutil.copytree(PACKAGE, extensions / "extension-canvas-design") - package = extensions / "extension-sample" - shutil.copytree(PACKAGE, package) - manifest = copy.deepcopy(self.manifest) - manifest["extension"].update(id="extension-sample", name="Sample Extension", version="1.2.3") - manifest["provides"]["config"] = [ - {"name": "sample-config.yml", "template": "config-template.yml"}, - ] - (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") - (package / "README.md").write_text("Sample extension", "utf-8") - (package / "config-template.yml").write_text("enabled: true\n", "utf-8") - catalog = copy.deepcopy(self.catalog) - entry = copy.deepcopy(catalog["extensions"]["extension-canvas-design"]) - tag = "extension-sample-v1.2.3" - entry.update( - id="extension-sample", name="Sample Extension", version="1.2.3", - download_url=f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/extension-sample.zip", - ) - catalog["extensions"]["extension-sample"] = entry - (extensions / "catalog.json").write_text(json.dumps(catalog), "utf-8") - cases = [ - ("workflow_dispatch", "refs/heads/main", ["extension-sample"]), - ("push", f"refs/tags/{tag}", ["extension-sample"]), - ("push", "refs/heads/main", ["extension-canvas-design", "extension-sample"]), - ("pull_request", "refs/pull/1/merge", ["extension-canvas-design", "extension-sample"]), - ] - for event, ref, expected_ids in cases: - with self.subTest(event=event, ref=ref): - output = root / "output" - output.write_text("") - result = subprocess.run( - [sys.executable, "-c", self.workflow_python("Validate release version")], - cwd=root, capture_output=True, text=True, - env=dict(os.environ, GITHUB_EVENT_NAME=event, GITHUB_REF=ref, - EXTENSION_ID="extension-sample", VERSION="1.2.3", - GITHUB_OUTPUT=str(output)), - ) - self.assertEqual(result.returncode, 0, result.stderr) - values = dict(line.split("=", 1) for line in output.read_text().splitlines()) - self.assertEqual(json.loads(values["extension_ids"]), expected_ids) - if len(expected_ids) == 1: - self.assertEqual(values["tag"], tag) - self.assertEqual(values["extension_id"], "extension-sample") - self.assertEqual(values["extension_name"], "Sample Extension") - else: - self.assertNotIn("tag", values) - result = subprocess.run( - [sys.executable, "-c", self.workflow_python("Create extension ZIP")], - cwd=root, capture_output=True, text=True, - env=dict(os.environ, EXTENSION_IDS=values["extension_ids"]), - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual({path.stem for path in root.glob("*.zip")}, set(expected_ids)) - with ZipFile(root / "extension-sample.zip") as archive: - self.assertEqual(set(archive.namelist()), FILES | {"config-template.yml"}) - for name in archive.namelist(): - self.assertEqual(archive.read(name), (package / name).read_bytes()) - for path in root.glob("*.zip"): - path.unlink() - - def test_packaging_rejects_invalid_declared_files(self): - cases = [ - ("commands", "file", "commands/load-page.md", "missing"), - ("templates", "file", "pages/setup.json", "missing"), - ("config", "template", "config-template.yml", "missing"), - ("commands", "file", "commands/load-page.md", "directory"), - ("templates", "file", "../outside.json", "outside"), - ("commands", "file", None, "invalid"), - ] - for kind, field, name, problem in cases: - with self.subTest(kind=kind, name=name, problem=problem), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - package = root / "spec-kit-extensions/extension-sample" - shutil.copytree(PACKAGE, package) - manifest = copy.deepcopy(self.manifest) - manifest["extension"]["id"] = "extension-sample" - manifest["provides"] = {kind: [{"name": "sample", field: name}]} - (package / "extension.yml").write_text(yaml.safe_dump(manifest), "utf-8") - if problem in ("missing", "directory"): - path = package / name - if path.is_file(): - path.unlink() - if problem == "directory": - path.mkdir() - elif problem == "outside": - (package / name).write_text("{}", "utf-8") - result = subprocess.run( - [sys.executable, "-c", self.workflow_python("Create extension ZIP")], - cwd=root, env=dict(os.environ, EXTENSION_IDS='["extension-sample"]'), - capture_output=True, text=True, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn(f"Declared {kind} {field} is not a regular package file", result.stderr) - self.assertIn(repr(name), result.stderr) - self.assertFalse((root / "extension-sample.zip").exists()) - - def test_release_triggers_and_permissions(self): - triggers = self.workflow["on"] - self.assertEqual(set(triggers), {"workflow_call", "pull_request", "push"}) - self.assertEqual(triggers["push"]["tags"], ["extension-*-v*"]) - self.assertEqual(triggers["pull_request"]["branches"], ["main"]) - self.assertEqual(self.workflow["permissions"], {"contents": "read"}) - self.assertEqual(triggers["workflow_call"]["inputs"], { - "extension_id": {"required": "true", "type": "string"}, - "version": {"required": "true", "type": "string"}, - }) - trigger = yaml.load( - (EXTENSIONS.parent / ".github/workflows/release-extension-trigger.yml").read_text("utf-8"), - Loader=yaml.BaseLoader, - ) - self.assertEqual(set(trigger["on"]), {"workflow_dispatch"}) - inputs = trigger["on"]["workflow_dispatch"]["inputs"] - self.assertEqual(inputs["extension_id"]["type"], "string") - self.assertEqual(inputs["extension_id"]["required"], "true") - self.assertEqual(inputs["version"]["required"], "true") - caller = trigger["jobs"]["tag-and-release"] - self.assertEqual(caller["uses"], "./.github/workflows/release-extension.yml") - self.assertEqual(caller["permissions"], {"contents": "write"}) - self.assertEqual(caller["with"], { - "extension_id": "${{ inputs.extension_id }}", - "version": "${{ inputs.version }}", - }) - for event in ("pull_request", "push"): - self.assertIn(".github/workflows/release-extension-trigger.yml", triggers[event]["paths"]) - release = self.workflow["jobs"]["release"] - self.assertEqual(release["needs"], "package") - self.assertEqual(release["permissions"], {"contents": "write"}) - self.assertEqual( - release["if"], - "github.event_name == 'workflow_dispatch' || " - "startsWith(github.ref, 'refs/tags/extension-')", - ) - publish = next( - step for step in release["steps"] - if step.get("name") == "Publish validated extension" - )["run"] - self.assertIn('gh release create "$TAG" "$EXTENSION_ID.zip"', publish) - self.assertIn('--title "$EXTENSION_ID ${TAG##*-}"', publish) - self.assertIn("--verify-tag", publish) - preset_workflow = yaml.load( - (EXTENSIONS.parent / ".github/workflows/release-preset.yml").read_text("utf-8"), - Loader=yaml.BaseLoader, - ) - self.assertEqual(preset_workflow["on"]["push"]["tags"], [ - "*-v[0-9]+.[0-9]+.[0-9]+", "!extension-*", - ]) - verify = next( - step for step in self.workflow["jobs"]["package"]["steps"] - if step.get("name") == "Verify release archive" - ) - self.assertEqual(verify["env"]["CANVAS_DESIGN_ARCHIVE"], "extension-canvas-design.zip") - - 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="extension-canvas-design-catalog-") as temporary: - root = Path(temporary) - package = root / "spec-kit-extensions/extension-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"]["extension-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_EVENT_NAME="push", - 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() From 20e474e21672aa98cd84f0e2a3650d1241f3e344 Mon Sep 17 00:00:00 2001 From: Nicole Haugen Date: Wed, 30 Sep 2026 17:14:53 -0500 Subject: [PATCH 6/7] Remove generated bytecode exposed by test cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_canvas_design.cpython-312.pyc | Bin 38237 -> 0 bytes ...test_extension_canvas_design.cpython-312.pyc | Bin 39590 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 spec-kit-extensions/tests/__pycache__/test_canvas_design.cpython-312.pyc delete mode 100644 spec-kit-extensions/tests/__pycache__/test_extension_canvas_design.cpython-312.pyc diff --git a/spec-kit-extensions/tests/__pycache__/test_canvas_design.cpython-312.pyc b/spec-kit-extensions/tests/__pycache__/test_canvas_design.cpython-312.pyc deleted file mode 100644 index f0cd29becebc907119c283c2df8ebfc1a8c7b751..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38237 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 Date: Wed, 30 Sep 2026 17:19:24 -0500 Subject: [PATCH 7/7] Keep extension README aligned with preset documentation Focus on purpose, provided pages, requirements, installation, operation, and license. Remove detailed provider protocol and preset-authoring reference material; check retained identifiers and claims against the manifest, catalog, command, and page files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension-canvas-design/README.md | 193 +++++------------- 1 file changed, 51 insertions(+), 142 deletions(-) diff --git a/spec-kit-extensions/extension-canvas-design/README.md b/spec-kit-extensions/extension-canvas-design/README.md index ac68cf31..40492e2b 100644 --- a/spec-kit-extensions/extension-canvas-design/README.md +++ b/spec-kit-extensions/extension-canvas-design/README.md @@ -1,165 +1,74 @@ # 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. +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 -From an initialized project, register the Copilot-specific extension catalog -once, then install by ID: +**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 ``` -Alternatively, install directly from a published release without registering -the catalog: +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 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-extension-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: [extension-canvas-design] -provides: - templates: - - type: template - name: canvas-settings-appearance - file: pages/appearance.json - strategy: replace -``` +The ZIP must be published before either installation method can succeed. +For a new Copilot project, initialize it first: -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: [extension-canvas-design] -provides: - templates: - - type: template - name: canvas-settings-accessibility - file: pages/accessibility.json - strategy: replace - - type: command - name: speckit.extension-canvas-design.load-page - file: commands/add-pages.md - strategy: append +```powershell +specify init . --integration copilot --integration-options="--skills" ``` -`commands/add-pages.md` contains instructions, without frontmatter: +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. -```markdown -## Additional Designer pages +## How It Works -- canvas-settings-accessibility -``` +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. -`pages/accessibility.json` contains, for example: - -```json -{ - "schemaVersion": 1, - "id": "canvas-settings-accessibility", - "title": "Accessibility", - "order": 50, - "enabled": true, - "fields": [] -} -``` +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 -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. +MIT