diff --git a/.github/workflows/release-extension-trigger.yml b/.github/workflows/release-extension-trigger.yml index 6b7882d5..6900dc10 100644 --- a/.github/workflows/release-extension-trigger.yml +++ b/.github/workflows/release-extension-trigger.yml @@ -1,5 +1,8 @@ name: Release Extension Trigger +# 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: @@ -8,19 +11,107 @@ on: 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 + + 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 + + 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 + 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 + 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..fef91701 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -1,220 +1,132 @@ 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 + - 'extension-*-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: Parse tag + id: parse + run: | + TAG="${GITHUB_REF#refs/tags/}" + # Extract extension id (everything before the last -vX.Y.Z) + EXTENSION_ID="${TAG%-v*}" + VERSION="${TAG##*-v}" - - name: Install manifest parser - run: python -m pip install "PyYAML>=6.0.2,<7" + # Validate version format + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format '$VERSION'" >&2 + exit 1 + fi - - 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 - - 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: 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 - - 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 - - - name: Upload validated archive - uses: actions/upload-artifact@v7 - with: - name: extension-packages - path: '*.zip' - if-no-files-found: error + 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 - 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 + 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 - - uses: actions/download-artifact@v8 - with: - name: extension-packages + # 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 - - name: Create or verify release tag + 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") + 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(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" + echo "Releasing $EXTENSION_ID v$VERSION" + + - 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 env: - TAG: ${{ needs.package.outputs.tag }} + EXTENSION_ID: ${{ steps.parse.outputs.extension_id }} + VERSION: ${{ steps.parse.outputs.version }} 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" + CHANGELOG="spec-kit-extensions/$EXTENSION_ID/CHANGELOG.md" + NOTES="$RUNNER_TEMP/release-notes.md" + + 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 "Error: Release tag '$TAG' is missing." >&2 - exit 1 + echo "Release $EXTENSION_ID v$VERSION" > "$NOTES" fi - - 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 }} + EXTENSION_ID: ${{ steps.parse.outputs.extension_id }} + TAG: ${{ steps.parse.outputs.tag }} + VERSION: ${{ steps.parse.outputs.version }} 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." + gh release create "$TAG" \ + "$EXTENSION_ID.zip" \ + --title "$EXTENSION_ID v$VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/.github/workflows/release-preset-trigger.yml b/.github/workflows/release-preset-trigger.yml index 867e6015..9413e287 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 @@ -40,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 @@ -54,6 +71,31 @@ 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 + 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 ff48d3f8..f9ef557e 100644 --- a/.github/workflows/release-preset.yml +++ b/.github/workflows/release-preset.yml @@ -7,8 +7,7 @@ name: Release Preset on: push: tags: - - '*-v[0-9]+.[0-9]+.[0-9]+' - - '!extension-*' + - 'copilot-*-v[0-9]+.[0-9]+.[0-9]+' jobs: release: @@ -25,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 @@ -33,32 +32,61 @@ 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('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" @@ -71,38 +99,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 3b779732..1af06ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,35 @@ 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 structurally +aligned with their preset counterparts and Spec Kit's release model. Only +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. +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. +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. +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 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..e27c886f 100644 --- a/spec-kit-extensions/README.md +++ b/spec-kit-extensions/README.md @@ -42,34 +42,58 @@ 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 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 +`.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. +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 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 +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. 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. 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 +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..e07e1732 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 @@ -98,3 +106,18 @@ 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. +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 +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.