From 7f66d25e6eecd13990d98825ed9255d7fe4f54ca Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:23:07 -0500 Subject: [PATCH 1/4] Align extension release workflows with presets Use the same tag-triggered release structure and RELEASE_PAT checkout credentials for both package types. Preserve the upstream extension-canvas-design rename and package cleanup, and document the shared tag-filter behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 70 ++++- .github/workflows/release-extension.yml | 277 ++++++------------ .github/workflows/release-preset-trigger.yml | 1 + .github/workflows/release-preset.yml | 1 - AGENTS.md | 15 + spec-kit-extensions/README.md | 73 +++-- spec-kit-presets/README.md | 11 +- 7 files changed, 209 insertions(+), 239 deletions(-) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 6b7882d5..347e6254 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -1,26 +1,76 @@ name: Release Extension Trigger +# Manually dispatch with a extension id + version; it validates and pushes a +# `-vX.Y.Z` tag, which fires release-extension.yml. + on: workflow_dispatch: inputs: extension_id: - description: 'Extension directory name under spec-kit-extensions/ (e.g., extension-canvas-design)' + description: 'Extension directory name under spec-kit-extensions/ (e.g., copilot-sub-agents)' required: true type: string version: - description: 'Version to release (e.g., 0.1.0; optional v prefix)' + description: 'Version to release (e.g., 1.0.0)' required: true type: string -permissions: - contents: read - jobs: tag-and-release: + runs-on: ubuntu-latest 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 }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.RELEASE_PAT }} + fetch-depth: 0 + + - name: Validate inputs + env: + EXTENSION_ID: ${{ github.event.inputs.extension_id }} + VERSION: ${{ github.event.inputs.version }} + run: | + # Strip optional v prefix + VERSION="${VERSION#v}" + + # Validate version format + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format '$VERSION'. Must be X.Y.Z" >&2 + exit 1 + fi + + # Validate extension directory exists + if [[ ! -d "spec-kit-extensions/$EXTENSION_ID" ]]; then + echo "Error: Extension directory 'spec-kit-extensions/$EXTENSION_ID' not found" >&2 + exit 1 + fi + + # Validate extension.yml exists + if [[ ! -f "spec-kit-extensions/$EXTENSION_ID/extension.yml" ]]; then + echo "Error: spec-kit-extensions/$EXTENSION_ID/extension.yml not found" >&2 + exit 1 + fi + + TAG="${EXTENSION_ID}-v${VERSION}" + + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: Tag '$TAG' already exists" >&2 + exit 1 + fi + + echo "tag=$TAG" >> "$GITHUB_ENV" + echo "Will create tag: $TAG" + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + run: | + git tag "$tag" + git push origin "$tag" + echo "Pushed tag $tag — release workflow will handle the rest" diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index e88808c3..9dcf0413 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -1,220 +1,107 @@ name: Release Extension +# Pushing a `-vX.Y.Z` tag builds that extension's zip inline and publishes it +# as a release asset — matching the `download_url` entries in +# spec-kit-extensions/catalog.json. + 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-*-v*' -permissions: - contents: read + - '*-v[0-9]+.[0-9]+.[0-9]+' jobs: - package: + release: runs-on: ubuntu-latest - outputs: - tag: ${{ steps.validate.outputs.tag }} - extension_id: ${{ steps.validate.outputs.extension_id }} - extension_name: ${{ steps.validate.outputs.extension_name }} + permissions: + contents: write steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-python@v7 - with: - python-version: '3.12' - - - name: Install manifest parser - run: python -m pip install "PyYAML>=6.0.2,<7" - - - name: Validate release version - id: validate - env: - EXTENSION_ID: ${{ inputs.extension_id }} - VERSION: ${{ inputs.version }} + - name: Parse tag + id: parse run: | - python - <<'PY' - import json - import os - import re - from pathlib import Path - import yaml + TAG="${GITHUB_REF#refs/tags/}" + # Extract extension id (everything before the last -vX.Y.Z) + EXTENSION_ID="${TAG%-v*}" + VERSION="${TAG#*-v}" - 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 + # Validate version format + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format '$VERSION'" >&2 + exit 1 + fi - - name: Create extension ZIP - env: - EXTENSION_IDS: ${{ steps.validate.outputs.extension_ids }} - run: | - python - <<'PY' - import json - import os - from pathlib import Path, PurePosixPath - from zipfile import ZIP_DEFLATED, ZipFile - import yaml + # Validate extension directory exists + if [[ ! -d "spec-kit-extensions/$EXTENSION_ID" ]]; then + echo "Error: Extension directory 'spec-kit-extensions/$EXTENSION_ID' not found" >&2 + exit 1 + fi - 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 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 + # Validate extension.yml exists + if [[ ! -f "spec-kit-extensions/$EXTENSION_ID/extension.yml" ]]; then + echo "Error: spec-kit-extensions/$EXTENSION_ID/extension.yml not found" >&2 + exit 1 + fi - - name: Upload validated archive - uses: actions/upload-artifact@v7 - with: - name: extension-packages - path: '*.zip' - if-no-files-found: error + # Ensure the tag version matches the manifest and the catalog entry, + # so a typo or direct tag push can't publish a mislabeled archive. + MANIFEST_VERSION=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('spec-kit-extensions/$EXTENSION_ID/extension.yml'))['extension']['version'])") + if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then + echo "Error: tag version '$VERSION' != extension.yml version '$MANIFEST_VERSION'" >&2 + exit 1 + fi - release: - needs: package - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/extension-') - runs-on: ubuntu-latest - permissions: - contents: write - concurrency: - 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 + CATALOG_VERSION=$(python3 -c "import json,sys; print(json.load(open('spec-kit-extensions/catalog.json'))['extensions']['$EXTENSION_ID']['version'])") + if [[ "$CATALOG_VERSION" != "$VERSION" ]]; then + echo "Error: tag version '$VERSION' != catalog.json version '$CATALOG_VERSION' for '$EXTENSION_ID'" >&2 + exit 1 + fi - - uses: actions/download-artifact@v8 - with: - name: extension-packages + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "extension_id=$EXTENSION_ID" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing $EXTENSION_ID v$VERSION" - - name: Create or verify release tag - env: - TAG: ${{ needs.package.outputs.tag }} + - name: Create extension zip + run: | + cd "spec-kit-extensions/${{ steps.parse.outputs.extension_id }}" + zip -r "$GITHUB_WORKSPACE/${{ steps.parse.outputs.extension_id }}.zip" . \ + -x '.*' '__pycache__/*' + + - name: Generate release notes + id: notes 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 + EXTENSION_ID="${{ steps.parse.outputs.extension_id }}" + VERSION="${{ steps.parse.outputs.version }}" + CHANGELOG="spec-kit-extensions/$EXTENSION_ID/CHANGELOG.md" + + { + echo 'body<&2 - exit 1 - fi + echo 'EOF' + } >> "$GITHUB_OUTPUT" - - name: Publish validated extension + - name: Create GitHub Release env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.package.outputs.tag }} - EXTENSION_ID: ${{ needs.package.outputs.extension_id }} - EXTENSION_NAME: ${{ needs.package.outputs.extension_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create "$TAG" "$EXTENSION_ID.zip" \ - --verify-tag \ - --latest=false \ - --title "$EXTENSION_ID ${TAG##*-}" \ - --notes "Specify CLI extension: $EXTENSION_NAME. See spec-kit-extensions/$EXTENSION_ID/README.md for installation and requirements." + EXTENSION_ID="${{ steps.parse.outputs.extension_id }}" + TAG="${{ steps.parse.outputs.tag }}" + VERSION="${{ steps.parse.outputs.version }}" + + gh release create "$TAG" \ + "$EXTENSION_ID.zip" \ + --title "$EXTENSION_ID v$VERSION" \ + --notes "${{ steps.notes.outputs.body }}" diff --git a/.github/workflows/release-preset-trigger.yml b/.github/workflows/release-preset-trigger.yml index 867e6015..a5791975 100644 --- a/.github/workflows/release-preset-trigger.yml +++ b/.github/workflows/release-preset-trigger.yml @@ -24,6 +24,7 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + token: ${{ secrets.RELEASE_PAT }} fetch-depth: 0 - name: Validate inputs diff --git a/.github/workflows/release-preset.yml b/.github/workflows/release-preset.yml index ff48d3f8..7f5e5118 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -8,7 +8,6 @@ on: push: tags: - '*-v[0-9]+.[0-9]+.[0-9]+' - - '!extension-*' jobs: release: diff --git a/AGENTS.md b/AGENTS.md index 3b779732..cab21237 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,21 @@ 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`. +Extensions are independently versioned and released through +`.github/workflows/release-extension-trigger.yml` (manual extension ID + version) +and `.github/workflows/release-extension.yml`. Keep both files verbatim copies +of their preset counterparts, changing only `preset`/`Preset`/`PRESET` to the +extension equivalents. The trigger validates its inputs, creates and pushes the +tag; the separate tag-push workflow validates, zips, and publishes in one job. +Do not add extension-only jobs, triggers, routing guards, or reusable calls. +Bump the manifest, catalog entry, and package README together before releasing. +Both trigger checkouts use `token: ${{ secrets.RELEASE_PAT }}` and persist that +credential for the tag push, matching Spec Kit's core release trigger. Configure +the repository Actions secret with a token authorized to push tags; do not +replace it with `GITHUB_TOKEN`, whose pushes do not trigger another workflow. +Both release workflows share the tag filter and retain the same directory +checks; the workflow for the other package type fails without its directory. + ## When revving the core skills plugin 1. Re-enumerate the `specify` CLI surface for the **latest** release diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index 06abbf8c..0b3274b0 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -42,34 +42,45 @@ To publish an extension from the GitHub Actions UI after merging those updates: 3. Leave **Use workflow from** set to **main**, enter the extension's directory name under `spec-kit-extensions/` (for example, `extension-canvas-design`), and enter its manifest version (for example, `0.1.0`; an optional `v` prefix is accepted). -4. Click **Run workflow** and monitor its packaging and release jobs. - -The trigger calls the reusable **Release Extension** workflow as part of the -same run. GitHub's built-in `GITHUB_TOKEN` can create tags and publish releases, -but tags pushed with it do not automatically start another workflow. Calling -the publisher directly avoids that limitation; no personal access token is -needed. The publisher validates the requested extension/version, manifest, -catalog version and download URL, builds and verifies the ZIP, then creates -`-vX.Y.Z` at the selected commit -and publishes `.zip`. The extension must have an `extension.yml` -and a matching entry in this directory's `catalog.json`; no workflow edit is -needed when adding another extension. - -Packaging rejects missing or non-file assets declared under `provides`, -including command files, templates, and configuration templates. The ZIP is -verified against the complete package file inventory before a tag is created. - -Direct pushes of `extension--vX.Y.Z` tags run the same publisher -for that extension. Pull requests and relevant pushes to `main` validate and -package every extension directory containing `extension.yml`; they do not -create tags or releases. -The preset publisher excludes `extension-*` tags so it does not attempt to -publish an extension as a preset. - -If tagging succeeds but no release is created, use **Re-run failed jobs** on the -original Actions run to retry the same commit. An existing tag is reused only -if it points to that commit; a tag pointing elsewhere is rejected and never -moved. Publication uses the same single `gh release create` command as preset -releases; existing releases are not overwritten or repaired automatically. -If a code fix is needed after tagging, release a new version instead of moving -the old tag. +4. Click **Run workflow** and monitor the trigger run and subsequent release run. + +Like **Release Preset Trigger**, the extension trigger validates its inputs, +checks that the extension directory and manifest exist, rejects existing tags, +then creates and pushes `-vX.Y.Z`. The separate **Release Extension** +workflow validates the tag version against the manifest and catalog, builds +`.zip` inline, generates release notes, and publishes the release +in one job. The extension must have an `extension.yml` and a matching entry in +this directory's `catalog.json`; no workflow edit is needed when adding one. + +Both extension workflow files are verbatim copies of their preset counterparts, +with only `preset`/`Preset`/`PRESET` replaced by their extension equivalents. +Keep them aligned; do not add separate jobs, reusable workflow calls, artifact +handoffs, or extension-only behavior. + +Both manual triggers require a repository Actions secret named **`RELEASE_PAT`** +containing a token authorized to push tags. Checkout persists this credential +for the tag push, matching Spec Kit's core release trigger and allowing the +separate release workflow to start. Do not use the default `GITHUB_TOKEN` for +this handoff: its tag pushes do not trigger another workflow. + +A maintainer can also push the tag directly: + +```bash +git tag extension-canvas-design-v0.1.0 +git push origin extension-canvas-design-v0.1.0 +``` + +The directory name is used unchanged in tags and ZIP names; no additional +prefix or suffix is added by the workflows. Both release workflows share the +same tag filter. Without a matching directory in both package trees, the other +package type's release workflow fails its directory check. This is retained +rather than adding routing guards. + +Release notes use the extension's `CHANGELOG.md` when present, otherwise a +short release title. ZIPs use the same shell `zip` command and exclusion patterns +as presets. Pull requests and branch pushes do not run the release workflow. + +If tagging succeeds but publication fails, retry the failed **Release Extension** +run, not the trigger (which rejects the existing tag). Existing releases are not +overwritten automatically. Release a new version if a code fix is needed after +tagging, rather than moving the old tag. diff --git a/spec-kit-presets/README.md b/spec-kit-presets/README.md index 2d6d9634..0008ae39 100644 --- a/spec-kit-presets/README.md +++ b/spec-kit-presets/README.md @@ -85,7 +85,15 @@ release-asset zip via each entry's `download_url`, tagged Releases are cut by CI — there is no local build script. The zip is built **inside** the release workflow (`.github/workflows/release-preset.yml`) from the preset -directory, so `preset.yml` and `commands/` sit at the archive root. To publish: +directory, so `preset.yml` and `commands/` sit at the archive root. + +The manual trigger requires a repository Actions secret named **`RELEASE_PAT`** +containing a token authorized to push tags. Checkout persists this credential for +the tag push, matching Spec Kit's core release trigger and allowing the separate +release workflow to start. Do not use the default `GITHUB_TOKEN` for this handoff: +its tag pushes do not trigger another workflow. + +To publish: - **Preferred:** run the **Release Preset Trigger** workflow (`.github/workflows/release-preset-trigger.yml`) via *Actions → Run workflow* with @@ -97,4 +105,3 @@ directory, so `preset.yml` and `commands/` sit at the archive root. To publish: Either path fires `release-preset.yml`, which builds the zip and creates the GitHub release with that asset. When revving a preset, bump its `preset.yml` version and the matching `catalog.json` entry together **before** tagging. - From 8573eb166519bfa29e0f23b3183eb486bcd4047a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:50:34 -0500 Subject: [PATCH 2/4] Address release workflow review findings Preserve the Spec Kit manual-trigger and separate publisher architecture while scoping package tags, parsing the final version delimiter, and passing release notes through a file. Correct extension help text and document the package-specific differences. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 4 +- .github/workflows/release-extension.yml | 48 +++++++++---------- .github/workflows/release-preset.yml | 48 +++++++++---------- AGENTS.md | 16 ++++--- spec-kit-extensions/README.md | 23 +++++---- spec-kit-presets/README.md | 6 +++ 6 files changed, 75 insertions(+), 70 deletions(-) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 347e6254..30fad9ea 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -1,13 +1,13 @@ name: Release Extension Trigger -# Manually dispatch with a extension id + version; it validates and pushes a +# Manually dispatch with an extension id + version; it validates and pushes a # `-vX.Y.Z` tag, which fires release-extension.yml. on: workflow_dispatch: inputs: extension_id: - description: 'Extension directory name under spec-kit-extensions/ (e.g., copilot-sub-agents)' + 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 9dcf0413..65bd7be5 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -7,7 +7,7 @@ name: Release Extension on: push: tags: - - '*-v[0-9]+.[0-9]+.[0-9]+' + - 'extension-*-v[0-9]+.[0-9]+.[0-9]+' jobs: release: @@ -24,7 +24,7 @@ jobs: TAG="${GITHUB_REF#refs/tags/}" # Extract extension id (everything before the last -vX.Y.Z) EXTENSION_ID="${TAG%-v*}" - VERSION="${TAG#*-v}" + VERSION="${TAG##*-v}" # Validate version format if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -70,38 +70,34 @@ jobs: -x '.*' '__pycache__/*' - name: Generate release notes - id: notes + env: + EXTENSION_ID: ${{ steps.parse.outputs.extension_id }} + VERSION: ${{ steps.parse.outputs.version }} run: | - EXTENSION_ID="${{ steps.parse.outputs.extension_id }}" - VERSION="${{ steps.parse.outputs.version }}" CHANGELOG="spec-kit-extensions/$EXTENSION_ID/CHANGELOG.md" + NOTES="$RUNNER_TEMP/release-notes.md" - { - echo 'body<> "$GITHUB_OUTPUT" + if [[ -f "$CHANGELOG" ]]; then + # Extract the section for this version from CHANGELOG.md + awk -v ver="$VERSION" ' + /^## \[/ { + if (found) exit + if (index($0, ver)) found=1 + } + found { print } + ' "$CHANGELOG" > "$NOTES" + else + echo "Release $EXTENSION_ID v$VERSION" > "$NOTES" + fi - name: Create GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXTENSION_ID: ${{ steps.parse.outputs.extension_id }} + TAG: ${{ steps.parse.outputs.tag }} + VERSION: ${{ steps.parse.outputs.version }} run: | - EXTENSION_ID="${{ steps.parse.outputs.extension_id }}" - TAG="${{ steps.parse.outputs.tag }}" - VERSION="${{ steps.parse.outputs.version }}" - gh release create "$TAG" \ "$EXTENSION_ID.zip" \ --title "$EXTENSION_ID v$VERSION" \ - --notes "${{ steps.notes.outputs.body }}" + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/.github/workflows/release-preset.yml b/.github/workflows/release-preset.yml index 7f5e5118..37a212e4 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -7,7 +7,7 @@ name: Release Preset on: push: tags: - - '*-v[0-9]+.[0-9]+.[0-9]+' + - 'copilot-*-v[0-9]+.[0-9]+.[0-9]+' jobs: release: @@ -24,7 +24,7 @@ jobs: TAG="${GITHUB_REF#refs/tags/}" # Extract preset id (everything before the last -vX.Y.Z) PRESET_ID="${TAG%-v*}" - VERSION="${TAG#*-v}" + VERSION="${TAG##*-v}" # Validate version format if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -70,38 +70,34 @@ jobs: -x '.*' '__pycache__/*' - name: Generate release notes - id: notes + env: + PRESET_ID: ${{ steps.parse.outputs.preset_id }} + VERSION: ${{ steps.parse.outputs.version }} run: | - PRESET_ID="${{ steps.parse.outputs.preset_id }}" - VERSION="${{ steps.parse.outputs.version }}" CHANGELOG="spec-kit-presets/$PRESET_ID/CHANGELOG.md" + NOTES="$RUNNER_TEMP/release-notes.md" - { - echo 'body<> "$GITHUB_OUTPUT" + if [[ -f "$CHANGELOG" ]]; then + # Extract the section for this version from CHANGELOG.md + awk -v ver="$VERSION" ' + /^## \[/ { + if (found) exit + if (index($0, ver)) found=1 + } + found { print } + ' "$CHANGELOG" > "$NOTES" + else + echo "Release $PRESET_ID v$VERSION" > "$NOTES" + fi - name: Create GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRESET_ID: ${{ steps.parse.outputs.preset_id }} + TAG: ${{ steps.parse.outputs.tag }} + VERSION: ${{ steps.parse.outputs.version }} run: | - PRESET_ID="${{ steps.parse.outputs.preset_id }}" - TAG="${{ steps.parse.outputs.tag }}" - VERSION="${{ steps.parse.outputs.version }}" - gh release create "$TAG" \ "$PRESET_ID.zip" \ --title "$PRESET_ID v$VERSION" \ - --notes "${{ steps.notes.outputs.body }}" + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/AGENTS.md b/AGENTS.md index cab21237..cb1d7aa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,18 +130,22 @@ the package identity. Standard manifest filenames remain `extension.yml`. Extensions are independently versioned and released through `.github/workflows/release-extension-trigger.yml` (manual extension ID + version) -and `.github/workflows/release-extension.yml`. Keep both files verbatim copies -of their preset counterparts, changing only `preset`/`Preset`/`PRESET` to the -extension equivalents. The trigger validates its inputs, creates and pushes the -tag; the separate tag-push workflow validates, zips, and publishes in one job. +and `.github/workflows/release-extension.yml`. Keep both files structurally +aligned with their preset counterparts and Spec Kit's release model. Only +package-specific tag filters, input examples, and terminology differ; apply +shared behavior fixes to both package types. The trigger validates its inputs, +creates and pushes the tag; the separate tag-push workflow validates, zips, and +publishes in one job. Do not add extension-only jobs, triggers, routing guards, or reusable calls. Bump the manifest, catalog entry, and package README together before releasing. Both trigger checkouts use `token: ${{ secrets.RELEASE_PAT }}` and persist that credential for the tag push, matching Spec Kit's core release trigger. Configure the repository Actions secret with a token authorized to push tags; do not replace it with `GITHUB_TOKEN`, whose pushes do not trigger another workflow. -Both release workflows share the tag filter and retain the same directory -checks; the workflow for the other package type fails without its directory. +Preset publishers listen for `copilot-*-vX.Y.Z` tags and extension publishers +listen for `extension-*-vX.Y.Z` tags, so releases do not start the other package +type's workflow. Parse versions after the final `-v`, and pass changelog notes +through a file rather than interpolating them into shell scripts. ## When revving the core skills plugin diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index 0b3274b0..ba68482e 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -52,10 +52,11 @@ workflow validates the tag version against the manifest and catalog, builds in one job. The extension must have an `extension.yml` and a matching entry in this directory's `catalog.json`; no workflow edit is needed when adding one. -Both extension workflow files are verbatim copies of their preset counterparts, -with only `preset`/`Preset`/`PRESET` replaced by their extension equivalents. -Keep them aligned; do not add separate jobs, reusable workflow calls, artifact -handoffs, or extension-only behavior. +Both extension workflow files follow the same structure as their preset +counterparts and Spec Kit's release model. Only package-specific tag filters, +input examples, and terminology differ. Keep shared behavior aligned; do not add +separate jobs, reusable workflow calls, artifact handoffs, or extension-only +behavior. Both manual triggers require a repository Actions secret named **`RELEASE_PAT`** containing a token authorized to push tags. Checkout persists this credential @@ -71,14 +72,16 @@ git push origin extension-canvas-design-v0.1.0 ``` The directory name is used unchanged in tags and ZIP names; no additional -prefix or suffix is added by the workflows. Both release workflows share the -same tag filter. Without a matching directory in both package trees, the other -package type's release workflow fails its directory check. This is retained -rather than adding routing guards. +prefix or suffix is added by the workflows. Extension publishers listen for +`extension-*-vX.Y.Z` tags; preset publishers listen for `copilot-*-vX.Y.Z` tags. +Each package release starts only its own publisher. Versions are parsed after +the final `-v`, including when a directory name itself contains `-v`. Release notes use the extension's `CHANGELOG.md` when present, otherwise a -short release title. ZIPs use the same shell `zip` command and exclusion patterns -as presets. Pull requests and branch pushes do not run the release workflow. +short release title. Notes are passed to GitHub CLI through a file so changelog +content is never interpolated into a shell script. ZIPs use the same shell `zip` +command and exclusion patterns as presets. Pull requests and branch pushes do not +run the release workflow. If tagging succeeds but publication fails, retry the failed **Release Extension** run, not the trigger (which rejects the existing tag). Existing releases are not diff --git a/spec-kit-presets/README.md b/spec-kit-presets/README.md index 0008ae39..6c2b8dda 100644 --- a/spec-kit-presets/README.md +++ b/spec-kit-presets/README.md @@ -105,3 +105,9 @@ To publish: Either path fires `release-preset.yml`, which builds the zip and creates the GitHub release with that asset. When revving a preset, bump its `preset.yml` version and the matching `catalog.json` entry together **before** tagging. + +The preset publisher listens only for `copilot-*-vX.Y.Z` tags, while extension +publishers listen for `extension-*-vX.Y.Z` tags. Their workflow structure remains +aligned with Spec Kit's manual-trigger and separate tag-push publisher model. +Versions are parsed after the final `-v`; changelog notes are passed through a +file rather than interpolated into shell scripts. From 7a9b13352cc1cf729874d55726c86fbda7d426a6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:20:15 -0500 Subject: [PATCH 3/4] Validate release versions and catalog URLs before tagging Check requested versions against manifests and catalogs in both manual triggers. Require catalog URLs to match the release repository, tag, and ZIP asset in triggers and publishers, preserving preset and extension workflow alignment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 19 +++++++++++++++++++ .github/workflows/release-extension.yml | 11 +++++++++-- .github/workflows/release-preset-trigger.yml | 19 +++++++++++++++++++ .github/workflows/release-preset.yml | 11 +++++++++-- AGENTS.md | 4 ++++ spec-kit-extensions/README.md | 10 +++++++--- spec-kit-presets/README.md | 6 ++++++ 7 files changed, 73 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 30fad9ea..1818108b 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -55,6 +55,25 @@ jobs: TAG="${EXTENSION_ID}-v${VERSION}" + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["version"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") + if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then + echo "Error: requested version '$VERSION' != extension.yml version '$MANIFEST_VERSION'" >&2 + exit 1 + fi + + CATALOG_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["extensions"][sys.argv[2]]["version"])' "spec-kit-extensions/catalog.json" "$EXTENSION_ID") + if [[ "$CATALOG_VERSION" != "$VERSION" ]]; then + echo "Error: requested version '$VERSION' != catalog.json version '$CATALOG_VERSION' for '$EXTENSION_ID'" >&2 + exit 1 + fi + + CATALOG_URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["extensions"][sys.argv[2]]["download_url"])' "spec-kit-extensions/catalog.json" "$EXTENSION_ID") + EXPECTED_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/${EXTENSION_ID}.zip" + if [[ "$CATALOG_URL" != "$EXPECTED_URL" ]]; then + echo "Error: catalog download_url '$CATALOG_URL' != expected release URL '$EXPECTED_URL'" >&2 + exit 1 + fi + # Check if tag already exists if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Error: Tag '$TAG' already exists" >&2 diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 65bd7be5..c7786423 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -46,18 +46,25 @@ jobs: # Ensure the tag version matches the manifest and the catalog entry, # so a typo or direct tag push can't publish a mislabeled archive. - MANIFEST_VERSION=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('spec-kit-extensions/$EXTENSION_ID/extension.yml'))['extension']['version'])") + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["version"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then echo "Error: tag version '$VERSION' != extension.yml version '$MANIFEST_VERSION'" >&2 exit 1 fi - CATALOG_VERSION=$(python3 -c "import json,sys; print(json.load(open('spec-kit-extensions/catalog.json'))['extensions']['$EXTENSION_ID']['version'])") + CATALOG_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["extensions"][sys.argv[2]]["version"])' "spec-kit-extensions/catalog.json" "$EXTENSION_ID") if [[ "$CATALOG_VERSION" != "$VERSION" ]]; then echo "Error: tag version '$VERSION' != catalog.json version '$CATALOG_VERSION' for '$EXTENSION_ID'" >&2 exit 1 fi + CATALOG_URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["extensions"][sys.argv[2]]["download_url"])' "spec-kit-extensions/catalog.json" "$EXTENSION_ID") + EXPECTED_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/${EXTENSION_ID}.zip" + if [[ "$CATALOG_URL" != "$EXPECTED_URL" ]]; then + echo "Error: catalog download_url '$CATALOG_URL' != expected release URL '$EXPECTED_URL'" >&2 + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "extension_id=$EXTENSION_ID" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-preset-trigger.yml b/.github/workflows/release-preset-trigger.yml index a5791975..a1207dec 100644 --- a/.github/workflows/release-preset-trigger.yml +++ b/.github/workflows/release-preset-trigger.yml @@ -55,6 +55,25 @@ jobs: TAG="${PRESET_ID}-v${VERSION}" + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["version"])' "spec-kit-presets/$PRESET_ID/preset.yml") + if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then + echo "Error: requested version '$VERSION' != preset.yml version '$MANIFEST_VERSION'" >&2 + exit 1 + fi + + CATALOG_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["presets"][sys.argv[2]]["version"])' "spec-kit-presets/catalog.json" "$PRESET_ID") + if [[ "$CATALOG_VERSION" != "$VERSION" ]]; then + echo "Error: requested version '$VERSION' != catalog.json version '$CATALOG_VERSION' for '$PRESET_ID'" >&2 + exit 1 + fi + + CATALOG_URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["presets"][sys.argv[2]]["download_url"])' "spec-kit-presets/catalog.json" "$PRESET_ID") + EXPECTED_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PRESET_ID}.zip" + if [[ "$CATALOG_URL" != "$EXPECTED_URL" ]]; then + echo "Error: catalog download_url '$CATALOG_URL' != expected release URL '$EXPECTED_URL'" >&2 + exit 1 + fi + # Check if tag already exists if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Error: Tag '$TAG' already exists" >&2 diff --git a/.github/workflows/release-preset.yml b/.github/workflows/release-preset.yml index 37a212e4..14675916 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -46,18 +46,25 @@ jobs: # Ensure the tag version matches the manifest and the catalog entry, # so a typo or direct tag push can't publish a mislabeled archive. - MANIFEST_VERSION=$(python3 -c "import yaml,sys; print(yaml.safe_load(open('spec-kit-presets/$PRESET_ID/preset.yml'))['preset']['version'])") + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["version"])' "spec-kit-presets/$PRESET_ID/preset.yml") if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then echo "Error: tag version '$VERSION' != preset.yml version '$MANIFEST_VERSION'" >&2 exit 1 fi - CATALOG_VERSION=$(python3 -c "import json,sys; print(json.load(open('spec-kit-presets/catalog.json'))['presets']['$PRESET_ID']['version'])") + CATALOG_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["presets"][sys.argv[2]]["version"])' "spec-kit-presets/catalog.json" "$PRESET_ID") if [[ "$CATALOG_VERSION" != "$VERSION" ]]; then echo "Error: tag version '$VERSION' != catalog.json version '$CATALOG_VERSION' for '$PRESET_ID'" >&2 exit 1 fi + CATALOG_URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["presets"][sys.argv[2]]["download_url"])' "spec-kit-presets/catalog.json" "$PRESET_ID") + EXPECTED_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PRESET_ID}.zip" + if [[ "$CATALOG_URL" != "$EXPECTED_URL" ]]; then + echo "Error: catalog download_url '$CATALOG_URL' != expected release URL '$EXPECTED_URL'" >&2 + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "preset_id=$PRESET_ID" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index cb1d7aa1..a01edd94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,10 @@ Preset publishers listen for `copilot-*-vX.Y.Z` tags and extension publishers listen for `extension-*-vX.Y.Z` tags, so releases do not start the other package type's workflow. Parse versions after the final `-v`, and pass changelog notes through a file rather than interpolating them into shell scripts. +Both manual triggers must verify the requested version against the manifest and +catalog before creating a tag. Both triggers and publishers must verify that the +catalog `download_url` matches the release repository, tag, and ZIP asset name; +keep these checks aligned so direct tag pushes cannot bypass them. ## When revving the core skills plugin diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index ba68482e..d9c36a3c 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -45,12 +45,16 @@ To publish an extension from the GitHub Actions UI after merging those updates: 4. Click **Run workflow** and monitor the trigger run and subsequent release run. Like **Release Preset Trigger**, the extension trigger validates its inputs, -checks that the extension directory and manifest exist, rejects existing tags, -then creates and pushes `-vX.Y.Z`. The separate **Release Extension** -workflow validates the tag version against the manifest and catalog, builds +checks that the extension directory and manifest exist, verifies the requested +version against both the manifest and catalog, and rejects existing tags before +creating and pushing `-vX.Y.Z`. Both the trigger and the separate +**Release Extension** workflow require the catalog's `download_url` to match +the release repository, tag, and `.zip` asset. The publisher +rechecks the tag version against the manifest and catalog before it builds `.zip` inline, generates release notes, and publishes the release in one job. The extension must have an `extension.yml` and a matching entry in this directory's `catalog.json`; no workflow edit is needed when adding one. +When bumping the catalog version, update its `download_url` to the new tag too. Both extension workflow files follow the same structure as their preset counterparts and Spec Kit's release model. Only package-specific tag filters, diff --git a/spec-kit-presets/README.md b/spec-kit-presets/README.md index 6c2b8dda..af1c22aa 100644 --- a/spec-kit-presets/README.md +++ b/spec-kit-presets/README.md @@ -106,6 +106,12 @@ Either path fires `release-preset.yml`, which builds the zip and creates the Git release with that asset. When revving a preset, bump its `preset.yml` version and the matching `catalog.json` entry together **before** tagging. +The trigger checks the requested version against the manifest and catalog before +creating any tag. Both the trigger and publisher also require the catalog's +`download_url` to match the release repository, tag, and `.zip` asset. +Update that URL when bumping the catalog version. Direct tag pushes receive the +same version and URL checks in the publisher before packaging or publication. + The preset publisher listens only for `copilot-*-vX.Y.Z` tags, while extension publishers listen for `extension-*-vX.Y.Z` tags. Their workflow structure remains aligned with Spec Kit's manual-trigger and separate tag-push publisher model. From 13272dd5be887ad9e3f976794140c07db63989cb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:38:15 -0500 Subject: [PATCH 4/4] Reject release identity drift and package symlinks Restore manifest ID checks before version validation in both triggers and publishers. Reject symlinked package roots and contents before parsing manifests or archiving, and enforce existing package ID namespaces to keep package paths contained. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../workflows/release-extension-trigger.yml | 22 +++++++++++++++++++ .github/workflows/release-extension.yml | 22 +++++++++++++++++++ .github/workflows/release-preset-trigger.yml | 22 +++++++++++++++++++ .github/workflows/release-preset.yml | 22 +++++++++++++++++++ AGENTS.md | 10 +++++++-- spec-kit-extensions/README.md | 20 +++++++++++------ spec-kit-presets/README.md | 4 ++++ 7 files changed, 113 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 1818108b..6900dc10 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -41,12 +41,28 @@ jobs: exit 1 fi + if [[ ! "$EXTENSION_ID" =~ ^extension-[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: Invalid extension ID '$EXTENSION_ID'. Must be extension- with lowercase letters, digits, and hyphens" >&2 + exit 1 + fi + + if [[ -L "spec-kit-extensions" || -L "spec-kit-extensions/$EXTENSION_ID" ]]; then + echo "Error: Extension package path must not be a symlink" >&2 + exit 1 + fi + # Validate extension directory exists if [[ ! -d "spec-kit-extensions/$EXTENSION_ID" ]]; then echo "Error: Extension directory 'spec-kit-extensions/$EXTENSION_ID' not found" >&2 exit 1 fi + SYMLINK=$(find -P "spec-kit-extensions/$EXTENSION_ID" -type l -print -quit) + if [[ -n "$SYMLINK" ]]; then + echo "Error: Cannot release package containing symlink '$SYMLINK'" >&2 + exit 1 + fi + # Validate extension.yml exists if [[ ! -f "spec-kit-extensions/$EXTENSION_ID/extension.yml" ]]; then echo "Error: spec-kit-extensions/$EXTENSION_ID/extension.yml not found" >&2 @@ -55,6 +71,12 @@ jobs: TAG="${EXTENSION_ID}-v${VERSION}" + MANIFEST_ID=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["id"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") + if [[ "$MANIFEST_ID" != "$EXTENSION_ID" ]]; then + echo "Error: extension.yml ID '$MANIFEST_ID' != directory '$EXTENSION_ID'" >&2 + exit 1 + fi + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["version"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then echo "Error: requested version '$VERSION' != extension.yml version '$MANIFEST_VERSION'" >&2 diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index c7786423..fef91701 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -32,18 +32,40 @@ jobs: exit 1 fi + if [[ ! "$EXTENSION_ID" =~ ^extension-[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: Invalid extension ID '$EXTENSION_ID'. Must be extension- with lowercase letters, digits, and hyphens" >&2 + exit 1 + fi + + if [[ -L "spec-kit-extensions" || -L "spec-kit-extensions/$EXTENSION_ID" ]]; then + echo "Error: Extension package path must not be a symlink" >&2 + exit 1 + fi + # Validate extension directory exists if [[ ! -d "spec-kit-extensions/$EXTENSION_ID" ]]; then echo "Error: Extension directory 'spec-kit-extensions/$EXTENSION_ID' not found" >&2 exit 1 fi + SYMLINK=$(find -P "spec-kit-extensions/$EXTENSION_ID" -type l -print -quit) + if [[ -n "$SYMLINK" ]]; then + echo "Error: Cannot release package containing symlink '$SYMLINK'" >&2 + exit 1 + fi + # Validate extension.yml exists if [[ ! -f "spec-kit-extensions/$EXTENSION_ID/extension.yml" ]]; then echo "Error: spec-kit-extensions/$EXTENSION_ID/extension.yml not found" >&2 exit 1 fi + MANIFEST_ID=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["id"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") + if [[ "$MANIFEST_ID" != "$EXTENSION_ID" ]]; then + echo "Error: extension.yml ID '$MANIFEST_ID' != directory '$EXTENSION_ID'" >&2 + exit 1 + fi + # Ensure the tag version matches the manifest and the catalog entry, # so a typo or direct tag push can't publish a mislabeled archive. MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["extension"]["version"])' "spec-kit-extensions/$EXTENSION_ID/extension.yml") diff --git a/.github/workflows/release-preset-trigger.yml b/.github/workflows/release-preset-trigger.yml index a1207dec..9413e287 100644 --- a/.github/workflows/release-preset-trigger.yml +++ b/.github/workflows/release-preset-trigger.yml @@ -41,12 +41,28 @@ jobs: exit 1 fi + if [[ ! "$PRESET_ID" =~ ^copilot-[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: Invalid preset ID '$PRESET_ID'. Must be copilot- with lowercase letters, digits, and hyphens" >&2 + exit 1 + fi + + if [[ -L "spec-kit-presets" || -L "spec-kit-presets/$PRESET_ID" ]]; then + echo "Error: Preset package path must not be a symlink" >&2 + exit 1 + fi + # Validate preset directory exists if [[ ! -d "spec-kit-presets/$PRESET_ID" ]]; then echo "Error: Preset directory 'spec-kit-presets/$PRESET_ID' not found" >&2 exit 1 fi + SYMLINK=$(find -P "spec-kit-presets/$PRESET_ID" -type l -print -quit) + if [[ -n "$SYMLINK" ]]; then + echo "Error: Cannot release package containing symlink '$SYMLINK'" >&2 + exit 1 + fi + # Validate preset.yml exists if [[ ! -f "spec-kit-presets/$PRESET_ID/preset.yml" ]]; then echo "Error: spec-kit-presets/$PRESET_ID/preset.yml not found" >&2 @@ -55,6 +71,12 @@ jobs: TAG="${PRESET_ID}-v${VERSION}" + MANIFEST_ID=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["id"])' "spec-kit-presets/$PRESET_ID/preset.yml") + if [[ "$MANIFEST_ID" != "$PRESET_ID" ]]; then + echo "Error: preset.yml ID '$MANIFEST_ID' != directory '$PRESET_ID'" >&2 + exit 1 + fi + MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["version"])' "spec-kit-presets/$PRESET_ID/preset.yml") if [[ "$MANIFEST_VERSION" != "$VERSION" ]]; then echo "Error: requested version '$VERSION' != preset.yml version '$MANIFEST_VERSION'" >&2 diff --git a/.github/workflows/release-preset.yml b/.github/workflows/release-preset.yml index 14675916..f9ef557e 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -32,18 +32,40 @@ jobs: exit 1 fi + if [[ ! "$PRESET_ID" =~ ^copilot-[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "Error: Invalid preset ID '$PRESET_ID'. Must be copilot- with lowercase letters, digits, and hyphens" >&2 + exit 1 + fi + + if [[ -L "spec-kit-presets" || -L "spec-kit-presets/$PRESET_ID" ]]; then + echo "Error: Preset package path must not be a symlink" >&2 + exit 1 + fi + # Validate preset directory exists if [[ ! -d "spec-kit-presets/$PRESET_ID" ]]; then echo "Error: Preset directory 'spec-kit-presets/$PRESET_ID' not found" >&2 exit 1 fi + SYMLINK=$(find -P "spec-kit-presets/$PRESET_ID" -type l -print -quit) + if [[ -n "$SYMLINK" ]]; then + echo "Error: Cannot release package containing symlink '$SYMLINK'" >&2 + exit 1 + fi + # Validate preset.yml exists if [[ ! -f "spec-kit-presets/$PRESET_ID/preset.yml" ]]; then echo "Error: spec-kit-presets/$PRESET_ID/preset.yml not found" >&2 exit 1 fi + MANIFEST_ID=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["id"])' "spec-kit-presets/$PRESET_ID/preset.yml") + if [[ "$MANIFEST_ID" != "$PRESET_ID" ]]; then + echo "Error: preset.yml ID '$MANIFEST_ID' != directory '$PRESET_ID'" >&2 + exit 1 + fi + # Ensure the tag version matches the manifest and the catalog entry, # so a typo or direct tag push can't publish a mislabeled archive. MANIFEST_VERSION=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1]))["preset"]["version"])' "spec-kit-presets/$PRESET_ID/preset.yml") diff --git a/AGENTS.md b/AGENTS.md index a01edd94..1af06ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,8 +132,8 @@ Extensions are independently versioned and released through `.github/workflows/release-extension-trigger.yml` (manual extension ID + version) and `.github/workflows/release-extension.yml`. Keep both files structurally aligned with their preset counterparts and Spec Kit's release model. Only -package-specific tag filters, input examples, and terminology differ; apply -shared behavior fixes to both package types. The trigger validates its inputs, +package-specific naming prefixes, tag filters, input examples, and terminology +differ; apply shared behavior fixes to both package types. The trigger validates its inputs, creates and pushes the tag; the separate tag-push workflow validates, zips, and publishes in one job. Do not add extension-only jobs, triggers, routing guards, or reusable calls. @@ -150,6 +150,12 @@ Both manual triggers must verify the requested version against the manifest and catalog before creating a tag. Both triggers and publishers must verify that the catalog `download_url` matches the release repository, tag, and ZIP asset name; keep these checks aligned so direct tag pushes cannot bypass them. +Require manifest IDs to match the selected directory in both triggers and +publishers. Package IDs use lowercase letters, digits, and single hyphen +separators with the `copilot-` preset or `extension-` extension prefix. Reject +symlinks in the package root or contents before reading manifests or creating +archives; shell `zip` follows links and must never archive files outside the +package. ## When revving the core skills plugin diff --git a/spec-kit-extensions/README.md b/spec-kit-extensions/README.md index d9c36a3c..e27c886f 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -45,9 +45,10 @@ To publish an extension from the GitHub Actions UI after merging those updates: 4. Click **Run workflow** and monitor the trigger run and subsequent release run. Like **Release Preset Trigger**, the extension trigger validates its inputs, -checks that the extension directory and manifest exist, verifies the requested -version against both the manifest and catalog, and rejects existing tags before -creating and pushing `-vX.Y.Z`. Both the trigger and the separate +checks that the extension directory and manifest exist and the declared ID +matches the directory, verifies the requested version against both the manifest +and catalog, and rejects existing tags before creating and pushing +`-vX.Y.Z`. Both the trigger and the separate **Release Extension** workflow require the catalog's `download_url` to match the release repository, tag, and `.zip` asset. The publisher rechecks the tag version against the manifest and catalog before it builds @@ -55,12 +56,17 @@ rechecks the tag version against the manifest and catalog before it builds in one job. The extension must have an `extension.yml` and a matching entry in this directory's `catalog.json`; no workflow edit is needed when adding one. When bumping the catalog version, update its `download_url` to the new tag too. +The publisher also checks the manifest ID, so direct tag pushes cannot publish +a package under the wrong identity. Package IDs use lowercase letters, digits, +and single hyphen separators after `extension-`. Both workflows reject package +symlinks, including directory, hidden, and dangling links, before reading +manifests or creating archives. Both extension workflow files follow the same structure as their preset -counterparts and Spec Kit's release model. Only package-specific tag filters, -input examples, and terminology differ. Keep shared behavior aligned; do not add -separate jobs, reusable workflow calls, artifact handoffs, or extension-only -behavior. +counterparts and Spec Kit's release model. Only package-specific naming prefixes, +tag filters, input examples, and terminology differ. Keep shared behavior aligned; +do not add separate jobs, reusable workflow calls, artifact handoffs, or +extension-only behavior. Both manual triggers require a repository Actions secret named **`RELEASE_PAT`** containing a token authorized to push tags. Checkout persists this credential diff --git a/spec-kit-presets/README.md b/spec-kit-presets/README.md index af1c22aa..e07e1732 100644 --- a/spec-kit-presets/README.md +++ b/spec-kit-presets/README.md @@ -111,6 +111,10 @@ creating any tag. Both the trigger and publisher also require the catalog's `download_url` to match the release repository, tag, and `.zip` asset. Update that URL when bumping the catalog version. Direct tag pushes receive the same version and URL checks in the publisher before packaging or publication. +Both workflows require the manifest ID to match the directory. Package IDs use +lowercase letters, digits, and single hyphen separators after `copilot-`. +Symlinks in the package root or contents (including hidden and dangling links) +are rejected before reading manifests or creating archives. The preset publisher listens only for `copilot-*-vX.Y.Z` tags, while extension publishers listen for `extension-*-vX.Y.Z` tags. Their workflow structure remains