Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/release-extension-trigger.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Release Extension Trigger

on:
workflow_dispatch:
inputs:
extension_id:
description: 'Extension directory name under spec-kit-extensions/ (e.g., extension-canvas-design)'
required: true
type: string
version:
description: 'Version to release (e.g., 0.1.0; optional v prefix)'
required: true
type: string

permissions:
contents: read

jobs:
tag-and-release:
permissions:
contents: write
# Call the publisher directly: GITHUB_TOKEN tag pushes do not start workflows.
uses: ./.github/workflows/release-extension.yml
with:
extension_id: ${{ inputs.extension_id }}
version: ${{ inputs.version }}
182 changes: 144 additions & 38 deletions .github/workflows/release-extension.yml
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
name: Release Extension

on:
workflow_call:
inputs:
extension_id:
required: true
type: string
version:
required: true
type: string
pull_request:
branches: [main]
paths:
- 'spec-kit-extensions/**'
- '.github/workflows/release-extension.yml'
- '.github/workflows/release-extension-trigger.yml'
push:
branches: [main]
paths:
- 'spec-kit-extensions/**'
- '.github/workflows/release-extension.yml'
- '.github/workflows/release-extension-trigger.yml'
tags:
- 'extension/canvas-design/v*'
- 'extension-*-v*'
permissions:
contents: read

jobs:
package:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.validate.outputs.tag }}
extension_id: ${{ steps.validate.outputs.extension_id }}
extension_name: ${{ steps.validate.outputs.extension_name }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -27,88 +41,180 @@ jobs:
with:
python-version: '3.12'

- name: Install package test dependencies
run: python -m pip install -r spec-kit-extensions/tests/requirements.txt
- name: Install manifest parser
run: python -m pip install "PyYAML>=6.0.2,<7"
Comment thread
nicolehaugen marked this conversation as resolved.

- name: Validate release version
id: validate
env:
EXTENSION_ID: ${{ inputs.extension_id }}
VERSION: ${{ inputs.version }}
run: |
python - <<'PY'
import json
import os
import re
from pathlib import Path
import yaml

with open("spec-kit-extensions/canvas-design/extension.yml") as source:
version = yaml.safe_load(source)["extension"]["version"]
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise SystemExit(f"Invalid manifest version: {version}")
tag = f"extension/canvas-design/v{version}"
with open("spec-kit-extensions/catalog.json") as source:
entry = json.load(source)["extensions"]["canvas-design"]
if entry["version"] != version:
raise SystemExit("Catalog version must match extension.yml version")
download_url = f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/canvas-design.zip"
if entry["download_url"] != download_url:
raise SystemExit("Catalog download URL must match the release asset")
if os.environ["GITHUB_REF"].startswith("refs/tags/"):
if os.environ["GITHUB_REF"] != f"refs/tags/{tag}":
root = Path("spec-kit-extensions")
ref = os.environ["GITHUB_REF"]
selected_id = ""
requested_version = None
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch":
selected_id = os.environ["EXTENSION_ID"]
if not selected_id:
raise SystemExit("Extension ID is required")
requested_version = os.environ["VERSION"].removeprefix("v")
elif ref.startswith("refs/tags/"):
match = re.fullmatch(r"refs/tags/(extension-[a-z0-9]+(?:-[a-z0-9]+)*)-v([0-9]+\.[0-9]+\.[0-9]+)", ref)
if not match:
raise SystemExit("Invalid extension release tag")
selected_id, requested_version = match.groups()
extension_ids = [selected_id] if selected_id else sorted(
path.parent.name for path in root.glob("*/extension.yml")
)
if not extension_ids:
raise SystemExit("No extension manifests found")
with open(root / "catalog.json") as source:
catalog = json.load(source)["extensions"]
outputs = {"extension_ids": json.dumps(extension_ids)}
for extension_id in extension_ids:
if not re.fullmatch(r"extension-[a-z0-9]+(?:-[a-z0-9]+)*", extension_id):
raise SystemExit(f"Invalid extension ID: {extension_id}")
package = root / extension_id
if package.is_symlink() or not (package / "extension.yml").is_file():
raise SystemExit(f"Extension directory must contain extension.yml: {extension_id}")
with open(package / "extension.yml") as source:
manifest = yaml.safe_load(source)
extension = manifest["extension"]
if extension["id"] != extension_id:
raise SystemExit("Manifest ID must match extension directory")
version = extension["version"]
if not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise SystemExit(f"Invalid manifest version: {version}")
if requested_version is not None and requested_version != version:
raise SystemExit("Requested version must match extension.yml version")
if extension_id not in catalog:
raise SystemExit(f"Extension is missing from catalog: {extension_id}")
entry = catalog[extension_id]
if entry["id"] != extension_id:
raise SystemExit("Catalog ID must match extension directory")
if entry["version"] != version:
raise SystemExit("Catalog version must match extension.yml version")
if entry["requires"] != manifest["requires"]:
raise SystemExit("Catalog requirements must match extension.yml requirements")
tag = f"{extension_id}-v{version}"
download_url = f"https://github.com/github/spec-kit-copilot/releases/download/{tag}/{extension_id}.zip"
if entry["download_url"] != download_url:
raise SystemExit("Catalog download URL must match the release asset")
if ref.startswith("refs/tags/") and ref != f"refs/tags/{tag}":
raise SystemExit("Release tag must match extension.yml version")
if selected_id:
name = extension["name"]
if not isinstance(name, str) or not name.strip() or "\n" in name or "\r" in name:
raise SystemExit("Extension name must be a nonempty single line")
outputs.update(tag=tag, extension_id=extension_id, extension_name=name)
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
for key, value in outputs.items():
output.write(f"{key}={value}\n")
PY

- name: Test package contracts
run: python -m unittest discover -s spec-kit-extensions/tests -v

- name: Create extension ZIP
env:
EXTENSION_IDS: ${{ steps.validate.outputs.extension_ids }}
run: |
python - <<'PY'
from pathlib import Path
import json
import os
from pathlib import Path, PurePosixPath
from zipfile import ZIP_DEFLATED, ZipFile
import yaml

package = Path("spec-kit-extensions/canvas-design")
with ZipFile("canvas-design.zip", "w", ZIP_DEFLATED) as archive:
for extension_id in json.loads(os.environ["EXTENSION_IDS"]):
package = Path("spec-kit-extensions") / extension_id
files = {}
for path in sorted(package.rglob("*")):
if path.is_symlink():
raise SystemExit(f"Cannot package symlink: {path}")
if path.is_file():
archive.write(path, path.relative_to(package).as_posix())
files[path.relative_to(package).as_posix()] = path
with open(package / "extension.yml") as source:
manifest = yaml.safe_load(source)
for kind, declarations in manifest.get("provides", {}).items():
for declaration in declarations:
for field in ("file", "template"):
if field not in declaration:
continue
name = declaration[field]
if not isinstance(name, str) or PurePosixPath(name).as_posix() not in files:
raise SystemExit(
f"Declared {kind} {field} is not a regular package file: "
f"{extension_id}/{name!r}"
)
with ZipFile(f"{extension_id}.zip", "w", ZIP_DEFLATED) as archive:
for name, path in files.items():
archive.write(path, name)
with ZipFile(f"{extension_id}.zip") as archive:
if archive.namelist() != list(files) or archive.testzip() is not None:
raise SystemExit(f"Invalid release archive: {extension_id}")
for name, path in files.items():
if archive.read(name) != path.read_bytes():
raise SystemExit(f"Archive content mismatch: {extension_id}/{name}")
PY

- name: Verify release archive
env:
CANVAS_DESIGN_ARCHIVE: canvas-design.zip
run: python -m unittest discover -s spec-kit-extensions/tests -v

- name: Upload validated archive
uses: actions/upload-artifact@v4
with:
name: canvas-design-package
path: canvas-design.zip
name: extension-packages
path: '*.zip'
if-no-files-found: error

release:
needs: package
if: startsWith(github.ref, 'refs/tags/extension/canvas-design/v')
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/extension-')
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: canvas-design-release
group: extension-release-${{ needs.package.outputs.extension_id }}
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- uses: actions/download-artifact@v4
with:
name: canvas-design-package
name: extension-packages

- name: Create or verify release tag
env:
TAG: ${{ needs.package.outputs.tag }}
run: |
if git show-ref --verify --quiet "refs/tags/$TAG"; then
Comment thread
nicolehaugen marked this conversation as resolved.
if [[ "$(git rev-parse "refs/tags/$TAG^{commit}")" != "$(git rev-parse HEAD)" ]]; then
echo "Error: Tag '$TAG' points to a different commit; refusing to move it." >&2
exit 1
fi
elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
git tag "$TAG" HEAD
git push origin "refs/tags/$TAG"
else
echo "Error: Release tag '$TAG' is missing." >&2
exit 1
fi

- name: Publish validated extension
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.package.outputs.tag }}
EXTENSION_ID: ${{ needs.package.outputs.extension_id }}
EXTENSION_NAME: ${{ needs.package.outputs.extension_name }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
gh release create "$TAG" canvas-design.zip \
gh release create "$TAG" "$EXTENSION_ID.zip" \
--verify-tag \
--latest=false \
--title "Canvas Design ${TAG##*/}" \
--notes "Specify CLI extension: page templates and the load-page command. Requires a separate compatible Designer provider exposing speckit_designer_load_pages; this package does not ship a Designer."
--title "$EXTENSION_ID ${TAG##*-}" \
--notes "Specify CLI extension: $EXTENSION_NAME. See spec-kit-extensions/$EXTENSION_ID/README.md for installation and requirements."
1 change: 1 addition & 0 deletions .github/workflows/release-preset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
push:
tags:
- '*-v[0-9]+.[0-9]+.[0-9]+'
- '!extension-*'

jobs:
release:
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>` (for example, `extension-canvas-design`). Release tags use
`<extension-id>-vX.Y.Z` and release titles use `<extension-id> 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

Expand Down
1 change: 0 additions & 1 deletion spec-kit-extensions/.gitignore

This file was deleted.

51 changes: 47 additions & 4 deletions spec-kit-extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -27,6 +27,49 @@ See the package README for provider requirements and direct-URL installation.

Each extension is versioned independently in its `extension.yml`. Update the
manifest, catalog entry, and package README version together.
Package IDs, directory names, and ZIP names use `extension-<name>`.
Tags use `<extension-id>-vX.Y.Z`, matching the preset version suffix, and release
titles use `<extension-id> vX.Y.Z`. For example, version `0.1.0` of
`extension-canvas-design` is tagged `extension-canvas-design-v0.1.0` and ships
`extension-canvas-design.zip`. Standard filenames such as `extension.yml` and
`README.md` are unchanged. The discovery tag `canvas-design` is independent of
the package ID and remains unchanged.

The **Release Extension** workflow validates and publishes Canvas Design as
`canvas-design.zip` when an `extension/canvas-design/vX.Y.Z` tag is pushed.
To publish an extension from the GitHub Actions UI after merging those updates:

1. Open **Actions** in `github/spec-kit-copilot`.
2. Select **Release Extension Trigger**, then **Run workflow**.
3. Leave **Use workflow from** set to **main**, enter the extension's directory
name under `spec-kit-extensions/` (for example, `extension-canvas-design`), and enter
its manifest version (for example, `0.1.0`; an optional `v` prefix is accepted).
4. Click **Run workflow** and monitor its packaging and release jobs.

The trigger calls the reusable **Release Extension** workflow as part of the
same run. GitHub's built-in `GITHUB_TOKEN` can create tags and publish releases,
but tags pushed with it do not automatically start another workflow. Calling
the publisher directly avoids that limitation; no personal access token is
needed. The publisher validates the requested extension/version, manifest,
catalog version and download URL, builds and verifies the ZIP, then creates
`<extension-id>-vX.Y.Z` at the selected commit
and publishes `<extension-id>.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-<name>-vX.Y.Z` tags run the same publisher
for that extension. Pull requests and relevant pushes to `main` validate and
package every extension directory containing `extension.yml`; they do not
create tags or releases.
The preset publisher excludes `extension-*` tags so it does not attempt to
publish an extension as a preset.

If tagging succeeds but no release is created, use **Re-run failed jobs** on the
original Actions run to retry the same commit. An existing tag is reused only
if it points to that commit; a tag pointing elsewhere is rejected and never
moved. Publication uses the same single `gh release create` command as preset
releases; existing releases are not overwritten or repaired automatically.
If a code fix is needed after tagging, release a new version instead of moving
the old tag.
Loading
Loading