Compare commits

..

4 Commits

Author SHA1 Message Date
github-actions[bot]
9dc09c0e66 chore: bump version to 0.1.12 2026-03-02 19:31:49 +00:00
Manfred Riem
78ed453e38 fix: use RELEASE_PAT so tag push triggers release workflow (#1736) 2026-03-02 13:31:18 -06:00
Manfred Riem
658ab2a38c fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
* fix: use release branch + PR instead of direct push to main

Bypass branch protection rules by pushing version bump to a
chore/release-vX.Y.Z branch, tagging that commit, then opening
an auto PR to merge back into main. The release workflow still
triggers immediately from the tag push.

* fix: remove --label automated from gh pr create (label does not exist)
2026-03-02 13:16:13 -06:00
Manfred Riem
2c41d3627e fix: Split release process to sync pyproject.toml version with git tags (#1732)
* fix: split release process to sync pyproject.toml version with git tags (#1721)

- Split release workflow into two: release-trigger.yml and release.yml
- release-trigger.yml: Updates pyproject.toml, generates changelog from commits, creates tag
- release.yml: Triggered by tag push, builds artifacts, creates GitHub release
- Ensures git tags point to commits with correct version in pyproject.toml
- Auto-generates changelog from commit messages since last tag
- Supports manual version input or auto-increment patch version
- Added simulate-release.sh for local testing without pushing
- Added comprehensive RELEASE-PROCESS.md documentation
- Updated pyproject.toml to v0.1.10 to sync with latest release

This fixes the version mismatch issue where tags pointed to commits with
outdated pyproject.toml versions, preventing confusion when installing from source.

* Update .github/workflows/RELEASE-PROCESS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/scripts/simulate-release.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/release.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/release-trigger.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden release-trigger against shell injection and fix stale docs

- Pass workflow_dispatch version input via env: instead of direct
  interpolation into shell script, preventing potential injection attacks
- Validate version input against strict semver regex before use
- Fix RELEASE-PROCESS.md Option 2 still referencing [Unreleased] section
  handling that no longer exists in the workflow

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 12:52:13 -06:00
12 changed files with 674 additions and 1176 deletions

191
.github/workflows/RELEASE-PROCESS.md vendored Normal file
View File

@@ -0,0 +1,191 @@
# Release Process
This document describes the automated release process for Spec Kit.
## Overview
The release process is split into two workflows to ensure version consistency:
1. **Release Trigger Workflow** (`release-trigger.yml`) - Manages versioning and triggers release
2. **Release Workflow** (`release.yml`) - Builds and publishes artifacts
This separation ensures that git tags always point to commits with the correct version in `pyproject.toml`.
## Before Creating a Release
**Important**: Write clear, descriptive commit messages!
### How CHANGELOG.md Works
The CHANGELOG is **automatically generated** from your git commit messages:
1. **During Development**: Write clear, descriptive commit messages:
```bash
git commit -m "feat: Add new authentication feature"
git commit -m "fix: Resolve timeout issue in API client (#123)"
git commit -m "docs: Update installation instructions"
```
2. **When Releasing**: The release trigger workflow automatically:
- Finds all commits since the last release tag
- Formats them as changelog entries
- Inserts them into CHANGELOG.md
- Commits the updated changelog before creating the new tag
### Commit Message Best Practices
Good commit messages make good changelogs:
- **Be descriptive**: "Add user authentication" not "Update files"
- **Reference issues/PRs**: Include `(#123)` for automated linking
- **Use conventional commits** (optional): `feat:`, `fix:`, `docs:`, `chore:`
- **Keep it concise**: One line is ideal, details go in commit body
**Example commits that become good changelog entries:**
```
fix: prepend YAML frontmatter to Cursor .mdc files (#1699)
feat: add generic agent support with customizable command directories (#1639)
docs: document dual-catalog system for extensions (#1689)
```
## Creating a Release
### Option 1: Auto-Increment (Recommended for patches)
1. Go to **Actions** → **Release Trigger**
2. Click **Run workflow**
3. Leave the version field **empty**
4. Click **Run workflow**
The workflow will:
- Auto-increment the patch version (e.g., `0.1.10` → `0.1.11`)
- Update `pyproject.toml`
- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag
- Commit changes to a `chore/release-vX.Y.Z` branch
- Create and push the git tag from that branch
- Open a PR to merge the version bump into `main`
- Trigger the release workflow automatically via the tag push
### Option 2: Manual Version (For major/minor bumps)
1. Go to **Actions** → **Release Trigger**
2. Click **Run workflow**
3. Enter the desired version (e.g., `0.2.0` or `v0.2.0`)
4. Click **Run workflow**
The workflow will:
- Use your specified version
- Update `pyproject.toml`
- Update `CHANGELOG.md` by adding a new section for the release based on commits since the last tag
- Commit changes to a `chore/release-vX.Y.Z` branch
- Create and push the git tag from that branch
- Open a PR to merge the version bump into `main`
- Trigger the release workflow automatically via the tag push
## What Happens Next
Once the release trigger workflow completes:
1. A `chore/release-vX.Y.Z` branch is pushed with the version bump commit
2. The git tag is pushed, pointing to that commit
3. The **Release Workflow** is automatically triggered by the tag push
4. Release artifacts are built for all supported agents
5. A GitHub Release is created with all assets
6. A PR is opened to merge the version bump branch into `main`
> **Note**: Merge the auto-opened PR after the release is published to keep `main` in sync.
## Workflow Details
### Release Trigger Workflow
**File**: `.github/workflows/release-trigger.yml`
**Trigger**: Manual (`workflow_dispatch`)
**Permissions Required**: `contents: write`
**Steps**:
1. Checkout repository
2. Determine version (manual or auto-increment)
3. Check if tag already exists (prevents duplicates)
4. Create `chore/release-vX.Y.Z` branch
5. Update `pyproject.toml`
6. Update `CHANGELOG.md` from git commits
7. Commit changes
8. Push branch and tag
9. Open PR to merge version bump into `main`
### Release Workflow
**File**: `.github/workflows/release.yml`
**Trigger**: Tag push (`v*`)
**Permissions Required**: `contents: write`
**Steps**:
1. Checkout repository at tag
2. Extract version from tag name
3. Check if release already exists
4. Build release package variants (all agents × shell/powershell)
5. Generate release notes from commits
6. Create GitHub Release with all assets
## Version Constraints
- Tags must follow format: `v{MAJOR}.{MINOR}.{PATCH}`
- Example valid versions: `v0.1.11`, `v0.2.0`, `v1.0.0`
- Auto-increment only bumps patch version
- Cannot create duplicate tags (workflow will fail)
## Benefits of This Approach
✅ **Version Consistency**: Git tags point to commits with matching `pyproject.toml` version
✅ **Single Source of Truth**: Version set once, used everywhere
✅ **Prevents Drift**: No more manual version synchronization needed
✅ **Clean Separation**: Versioning logic separate from artifact building
✅ **Flexibility**: Supports both auto-increment and manual versioning
## Troubleshooting
### No Commits Since Last Release
If you run the release trigger workflow when there are no new commits since the last tag:
- The workflow will still succeed
- The CHANGELOG will show "- Initial release" if it's the first release
- Or it will be empty if there are no commits
- Consider adding meaningful commits before releasing
**Best Practice**: Use descriptive commit messages - they become your changelog!
### Tag Already Exists
If you see "Error: Tag vX.Y.Z already exists!", you need to:
- Choose a different version number, or
- Delete the existing tag if it was created in error
### Release Workflow Didn't Trigger
Check that:
- The release trigger workflow completed successfully
- The tag was pushed (check repository tags)
- The release workflow is enabled in Actions settings
### Version Mismatch
If `pyproject.toml` doesn't match the latest tag:
- Run the release trigger workflow to sync versions
- Or manually update `pyproject.toml` and push changes before running the release trigger
## Legacy Behavior (Pre-v0.1.10)
Before this change, the release workflow:
- Created tags automatically on main branch pushes
- Updated `pyproject.toml` AFTER creating the tag
- Resulted in tags pointing to commits with outdated versions
This has been fixed in v0.1.10+.

161
.github/workflows/release-trigger.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Release Trigger
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.1.11). Leave empty to auto-increment patch version.'
required: false
type: string
jobs:
bump-version:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Determine version
id: version
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
if [[ -n "$INPUT_VERSION" ]]; then
# Manual version specified - strip optional v prefix
VERSION="${INPUT_VERSION#v}"
# Validate strict semver format to prevent injection
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format '$VERSION'. Must be X.Y.Z (e.g. 1.2.3 or v1.2.3)"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "Using manual version: $VERSION"
else
# Auto-increment patch version
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "Latest tag: $LATEST_TAG"
# Extract version number and increment
VERSION=$(echo $LATEST_TAG | sed 's/v//')
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
MAJOR=${VERSION_PARTS[0]:-0}
MINOR=${VERSION_PARTS[1]:-0}
PATCH=${VERSION_PARTS[2]:-0}
# Increment patch version
PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
echo "Auto-incremented version: $NEW_VERSION"
fi
- name: Check if tag already exists
run: |
if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "Error: Tag ${{ steps.version.outputs.tag }} already exists!"
exit 1
fi
- name: Create release branch
run: |
BRANCH="chore/release-${{ steps.version.outputs.tag }}"
git checkout -b "$BRANCH"
echo "branch=$BRANCH" >> $GITHUB_ENV
- name: Update pyproject.toml
run: |
sed -i "s/version = \".*\"/version = \"${{ steps.version.outputs.version }}\"/" pyproject.toml
echo "Updated pyproject.toml to version ${{ steps.version.outputs.version }}"
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Get the previous tag to compare commits
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "Generating changelog from commits..."
if [[ -n "$PREVIOUS_TAG" ]]; then
echo "Changes since $PREVIOUS_TAG"
COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release")
else
echo "No previous tag found - this is the first release"
COMMITS="- Initial release"
fi
# Create new changelog entry
{
head -n 8 CHANGELOG.md
echo ""
echo "## [${{ steps.version.outputs.version }}] - $DATE"
echo ""
echo "### Changed"
echo ""
echo "$COMMITS"
echo ""
tail -n +9 CHANGELOG.md
} > CHANGELOG.md.tmp
mv CHANGELOG.md.tmp CHANGELOG.md
echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG"
else
echo "No CHANGELOG.md found"
fi
- name: Commit version bump
run: |
if [ -f "CHANGELOG.md" ]; then
git add pyproject.toml CHANGELOG.md
else
git add pyproject.toml
fi
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: bump version to ${{ steps.version.outputs.version }}"
echo "Changes committed"
fi
- name: Create and push tag
run: |
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ env.branch }}"
git push origin "${{ steps.version.outputs.tag }}"
echo "Branch ${{ env.branch }} and tag ${{ steps.version.outputs.tag }} pushed"
- name: Open pull request
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
run: |
gh pr create \
--base main \
--head "${{ env.branch }}" \
--title "chore: bump version to ${{ steps.version.outputs.version }}" \
--body "Automated version bump to ${{ steps.version.outputs.version }}.
This PR was created by the Release Trigger workflow. The git tag \`${{ steps.version.outputs.tag }}\` has already been pushed and the release artifacts are being built.
Merge this PR to record the version bump and changelog update on \`main\`."
- name: Summary
run: |
echo "✅ Version bumped to ${{ steps.version.outputs.version }}"
echo "✅ Tag ${{ steps.version.outputs.tag }} created and pushed"
echo "✅ PR opened to merge version bump into main"
echo "🚀 Release workflow is building artifacts from the tag"

View File

@@ -2,68 +2,60 @@ name: Create Release
on:
push:
branches: [ main ]
paths:
- 'memory/**'
- 'scripts/**'
- 'src/**'
- 'templates/**'
- '.github/workflows/**'
workflow_dispatch:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get latest tag
id: get_tag
- name: Extract version from tag
id: version
run: |
chmod +x .github/workflows/scripts/get-next-version.sh
.github/workflows/scripts/get-next-version.sh
VERSION=${GITHUB_REF#refs/tags/}
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "Building release for $VERSION"
- name: Check if release already exists
id: check_release
run: |
chmod +x .github/workflows/scripts/check-release-exists.sh
.github/workflows/scripts/check-release-exists.sh ${{ steps.get_tag.outputs.new_version }}
.github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release package variants
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/create-release-packages.sh
.github/workflows/scripts/create-release-packages.sh ${{ steps.get_tag.outputs.new_version }}
.github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}
- name: Generate release notes
if: steps.check_release.outputs.exists == 'false'
id: release_notes
run: |
chmod +x .github/workflows/scripts/generate-release-notes.sh
.github/workflows/scripts/generate-release-notes.sh ${{ steps.get_tag.outputs.new_version }} ${{ steps.get_tag.outputs.latest_tag }}
# Get the previous tag for changelog generation
PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "")
# Default to v0.0.0 if no previous tag is found (e.g., first release)
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG="v0.0.0"
fi
.github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG"
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/create-github-release.sh
.github/workflows/scripts/create-github-release.sh ${{ steps.get_tag.outputs.new_version }}
.github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update version in pyproject.toml (for release artifacts only)
if: steps.check_release.outputs.exists == 'false'
run: |
chmod +x .github/workflows/scripts/update-version.sh
.github/workflows/scripts/update-version.sh ${{ steps.get_tag.outputs.new_version }}
- name: Commit version bump to main
if: steps.check_release.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git diff --cached --quiet || git commit -m "chore: bump version to ${{ steps.get_tag.outputs.new_version }} [skip ci]"
git push

161
.github/workflows/scripts/simulate-release.sh vendored Executable file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
# simulate-release.sh
# Simulate the release process locally without pushing to GitHub
# Usage: simulate-release.sh [version]
# If version is omitted, auto-increments patch version
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}"
echo "======================================"
echo ""
# Step 1: Determine version
if [[ -n "${1:-}" ]]; then
VERSION="${1#v}"
TAG="v$VERSION"
echo -e "${GREEN}📝 Using manual version: $VERSION${NC}"
else
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}"
VERSION=$(echo $LATEST_TAG | sed 's/v//')
IFS='.' read -ra VERSION_PARTS <<< "$VERSION"
MAJOR=${VERSION_PARTS[0]:-0}
MINOR=${VERSION_PARTS[1]:-0}
PATCH=${VERSION_PARTS[2]:-0}
PATCH=$((PATCH + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
TAG="v$VERSION"
echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}"
fi
echo ""
# Step 2: Check if tag exists
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}"
echo " Please use a different version or delete the tag first."
exit 1
fi
echo -e "${GREEN}✓ Tag $TAG is available${NC}"
# Step 3: Backup current state
echo ""
echo -e "${YELLOW}💾 Creating backup of current state...${NC}"
BACKUP_DIR=$(mktemp -d)
cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak"
cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak"
echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}"
# Step 4: Update pyproject.toml
echo ""
echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}"
sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm -f pyproject.toml.tmp
echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}"
# Step 5: Update CHANGELOG.md
echo ""
echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}"
DATE=$(date +%Y-%m-%d)
# Get the previous tag to compare commits
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -n "$PREVIOUS_TAG" ]]; then
echo " Generating changelog from commits since $PREVIOUS_TAG"
# Get commits since last tag, format as bullet points
COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release")
else
echo " No previous tag found - this is the first release"
COMMITS="- Initial release"
fi
# Create temp file with new entry
{
head -n 8 CHANGELOG.md
echo ""
echo "## [$VERSION] - $DATE"
echo ""
echo "### Changed"
echo ""
echo "$COMMITS"
echo ""
tail -n +9 CHANGELOG.md
} > CHANGELOG.md.tmp
mv CHANGELOG.md.tmp CHANGELOG.md
echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}"
# Step 6: Show what would be committed
echo ""
echo -e "${YELLOW}📋 Changes that would be committed:${NC}"
git diff pyproject.toml CHANGELOG.md
# Step 7: Create temporary tag (no push)
echo ""
echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}"
git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true
echo -e "${GREEN}✓ Tag $TAG created locally${NC}"
# Step 8: Simulate release artifact creation
echo ""
echo -e "${YELLOW}📦 Simulating release package creation...${NC}"
echo " (High-level simulation only; packaging script is not executed)"
echo ""
# Check if script exists and is executable
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then
echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}"
echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\""
echo ""
echo "This simulation does not enumerate individual package files to avoid"
echo "drifting from the actual behavior of create-release-packages.sh."
else
echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}"
fi
# Step 9: Simulate release notes generation
echo ""
echo -e "${YELLOW}📄 Simulating release notes generation...${NC}"
echo ""
PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "")
if [[ -n "$PREVIOUS_TAG" ]]; then
echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}"
git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10
echo ""
else
echo -e "${BLUE}No previous tag found - this would be the first release${NC}"
fi
# Step 10: Summary
echo ""
echo -e "${GREEN}🎉 Simulation Complete!${NC}"
echo "======================================"
echo ""
echo -e "${BLUE}Summary:${NC}"
echo " Version: $VERSION"
echo " Tag: $TAG"
echo " Backup: $BACKUP_DIR"
echo ""
echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo " 1. Review the changes above"
echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit"
echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG"
echo " 4. To restore from backup: cp $BACKUP_DIR/* ."
echo ""
echo -e "${BLUE}To run the actual release:${NC}"
echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml"
echo " Click 'Run workflow' and enter version: $VERSION"
echo ""

View File

@@ -7,24 +7,53 @@ Recent changes to the Specify CLI and templates are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.12] - 2026-03-02
### Changed
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
## [0.1.10] - 2026-03-02
### Fixed
- **Version Sync Issue (#1721)**: Fixed version mismatch between `pyproject.toml` and git release tags
- Split release process into two workflows: `release-trigger.yml` for version management and `release.yml` for artifact building
- Version bump now happens BEFORE tag creation, ensuring tags point to commits with correct version
- Supports both manual version specification and auto-increment (patch version)
- Git tags now accurately reflect the version in `pyproject.toml` at that commit
- Prevents confusion when installing from source
## [0.1.9] - 2026-02-28
### Changed
- Updated dependency: bumped astral-sh/setup-uv from 6 to 7
## [0.1.8] - 2026-02-28
### Changed
- Updated dependency: bumped actions/setup-python from 5 to 6
## [0.1.7] - 2026-02-27
### Changed
- Updated outdated GitHub Actions versions
- Documented dual-catalog system for extensions
### Fixed
- Fixed version command in documentation
### Added
- **Multi-Catalog Support (#1707)**: Extension catalog system now supports multiple active catalogs simultaneously via a catalog stack
- New `specify extension catalogs` command lists all active catalogs with name, URL, priority, and `install_allowed` status
- New `specify extension catalog add` and `specify extension catalog remove` commands for project-scoped catalog management
- Default built-in stack includes `catalog.json` (org-approved, installable) and `catalog.community.json` (discovery only) — community extensions are now surfaced in search results out of the box
- `specify extension search` aggregates results across all active catalogs, annotating each result with source catalog
- `specify extension add` enforces `install_allowed` policy — extensions from discovery-only catalogs cannot be installed directly
- Project-level `.specify/extension-catalogs.yml` and user-level `~/.specify/extension-catalogs.yml` config files supported, with project-level taking precedence
- `SPECKIT_CATALOG_URL` environment variable still works for backward compatibility (replaces full stack with single catalog)
- All catalog URLs require HTTPS (HTTP allowed for localhost development)
- New `CatalogEntry` dataclass in `extensions.py` for catalog stack representation
- Per-URL hash-based caching for non-default catalogs; legacy cache preserved for default catalog
- Higher-priority catalogs win on merge conflicts (same extension id in multiple catalogs)
- 13 new tests covering catalog stack resolution, merge conflicts, URL validation, and `install_allowed` enforcement
- Updated RFC, Extension User Guide, and Extension API Reference documentation
- Added Cleanup Extension to README
- Added retrospective extension to community catalog
## [0.1.6] - 2026-02-23

View File

@@ -243,32 +243,6 @@ manager.check_compatibility(
) # Raises: CompatibilityError if incompatible
```
### CatalogEntry
**Module**: `specify_cli.extensions`
Represents a single catalog in the active catalog stack.
```python
from specify_cli.extensions import CatalogEntry
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="org-approved",
priority=1,
install_allowed=True,
)
```
**Fields**:
| Field | Type | Description |
|-------|------|-------------|
| `url` | `str` | Catalog URL (must use HTTPS, or HTTP for localhost) |
| `name` | `str` | Human-readable catalog name |
| `priority` | `int` | Sort order (lower = higher priority, wins on conflicts) |
| `install_allowed` | `bool` | Whether extensions from this catalog can be installed |
### ExtensionCatalog
**Module**: `specify_cli.extensions`
@@ -279,65 +253,30 @@ from specify_cli.extensions import ExtensionCatalog
catalog = ExtensionCatalog(project_root)
```
**Class attributes**:
```python
ExtensionCatalog.DEFAULT_CATALOG_URL # org-approved catalog URL
ExtensionCatalog.COMMUNITY_CATALOG_URL # community catalog URL
```
**Methods**:
```python
# Get the ordered list of active catalogs
entries = catalog.get_active_catalogs() # List[CatalogEntry]
# Fetch catalog (primary catalog, backward compat)
# Fetch catalog
catalog_data = catalog.fetch_catalog(force_refresh: bool = False) # Dict
# Search extensions across all active catalogs
# Each result includes _catalog_name and _install_allowed
# Search extensions
results = catalog.search(
query: Optional[str] = None,
tag: Optional[str] = None,
author: Optional[str] = None,
verified_only: bool = False
) # Returns: List[Dict] — each dict includes _catalog_name, _install_allowed
) # Returns: List[Dict]
# Get extension info (searches all active catalogs)
# Returns None if not found; includes _catalog_name and _install_allowed
# Get extension info
ext_info = catalog.get_extension_info(extension_id: str) # Optional[Dict]
# Check cache validity (primary catalog)
# Check cache validity
is_valid = catalog.is_cache_valid() # bool
# Clear all catalog caches
# Clear cache
catalog.clear_cache()
```
**Result annotation fields**:
Each extension dict returned by `search()` and `get_extension_info()` includes:
| Field | Type | Description |
|-------|------|-------------|
| `_catalog_name` | `str` | Name of the source catalog |
| `_install_allowed` | `bool` | Whether installation is allowed from this catalog |
**Catalog config file** (`.specify/extension-catalogs.yml`):
```yaml
catalogs:
- name: "org-approved"
url: "https://example.com/catalog.json"
priority: 1
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 2
install_allowed: false
```
### HookExecutor
**Module**: `specify_cli.extensions`
@@ -604,38 +543,6 @@ EXECUTE_COMMAND: {command}
**Output**: List of installed extensions with metadata
### extension catalogs
**Usage**: `specify extension catalogs`
Lists all active catalogs in the current catalog stack, showing name, URL, priority, and `install_allowed` status.
### extension catalog add
**Usage**: `specify extension catalog add URL [OPTIONS]`
**Options**:
- `--name NAME` - Catalog name (required)
- `--priority INT` - Priority (lower = higher priority, default: 10)
- `--install-allowed / --no-install-allowed` - Allow installs from this catalog (default: false)
**Arguments**:
- `URL` - Catalog URL (must use HTTPS)
Adds a catalog entry to `.specify/extension-catalogs.yml`.
### extension catalog remove
**Usage**: `specify extension catalog remove NAME`
**Arguments**:
- `NAME` - Catalog name to remove
Removes a catalog entry from `.specify/extension-catalogs.yml`.
### extension add
**Usage**: `specify extension add EXTENSION [OPTIONS]`
@@ -644,13 +551,13 @@ Removes a catalog entry from `.specify/extension-catalogs.yml`.
- `--from URL` - Install from custom URL
- `--dev PATH` - Install from local directory
- `--version VERSION` - Install specific version
- `--no-register` - Skip command registration
**Arguments**:
- `EXTENSION` - Extension name or URL
**Note**: Extensions from catalogs with `install_allowed: false` cannot be installed via this command.
### extension remove
**Usage**: `specify extension remove EXTENSION [OPTIONS]`
@@ -668,8 +575,6 @@ Removes a catalog entry from `.specify/extension-catalogs.yml`.
**Usage**: `specify extension search [QUERY] [OPTIONS]`
Searches all active catalogs simultaneously. Results include source catalog name and install_allowed status.
**Options**:
- `--tag TAG` - Filter by tag
@@ -684,8 +589,6 @@ Searches all active catalogs simultaneously. Results include source catalog name
**Usage**: `specify extension info EXTENSION`
Shows source catalog and install_allowed status.
**Arguments**:
- `EXTENSION` - Extension ID

View File

@@ -76,7 +76,7 @@ vim .specify/extensions/jira/jira-config.yml
## Finding Extensions
`specify extension search` searches **all active catalogs** simultaneously, including the community catalog by default. Results are annotated with their source catalog and install status.
**Note**: By default, `specify extension search` uses your organization's catalog (`catalog.json`). If the catalog is empty, you won't see any results. See [Extension Catalogs](#extension-catalogs) to learn how to populate your catalog from the community reference catalog.
### Browse All Extensions
@@ -84,7 +84,7 @@ vim .specify/extensions/jira/jira-config.yml
specify extension search
```
Shows all extensions across all active catalogs (org-approved and community by default).
Shows all extensions in your organization's catalog.
### Search by Keyword
@@ -402,13 +402,13 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
| `SPECKIT_CATALOG_URL` | Override the extension catalog URL | GitHub-hosted catalog |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
#### Example: Using a custom catalog for testing
```bash
# Point to a local or alternative catalog (replaces the full stack)
# Point to a local or alternative catalog
export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
# Or use a staging catalog
@@ -419,73 +419,13 @@ export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
## Extension Catalogs
Spec Kit uses a **catalog stack** — an ordered list of catalogs searched simultaneously. By default, two catalogs are active:
| Priority | Catalog | Install Allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | ✅ Yes | Extensions your org approves for installation |
| 2 | `catalog.community.json` (community) | ❌ No (discovery only) | Browse community extensions |
### Listing Active Catalogs
```bash
specify extension catalogs
```
### Adding a Catalog (Project-scoped)
```bash
# Add an internal catalog that allows installs
specify extension catalog add \
--name "internal" \
--priority 2 \
--install-allowed \
https://internal.company.com/spec-kit/catalog.json
# Add a discovery-only catalog
specify extension catalog add \
--name "partner" \
--priority 5 \
https://partner.example.com/spec-kit/catalog.json
```
This creates or updates `.specify/extension-catalogs.yml`.
### Removing a Catalog
```bash
specify extension catalog remove internal
```
### Manual Config File
You can also edit `.specify/extension-catalogs.yml` directly:
```yaml
catalogs:
- name: "org-approved"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1
install_allowed: true
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3
install_allowed: false
```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. Project-level config takes full precedence when present.
For information about how Spec Kit's dual-catalog system works (`catalog.json` vs `catalog.community.json`), see the main [Extensions README](README.md#extension-catalogs).
## Organization Catalog Customization
### Why Customize Your Catalog
Organizations customize their catalogs to:
Organizations customize their `catalog.json` to:
- **Control available extensions** - Curate which extensions your team can install
- **Host private extensions** - Internal tools that shouldn't be public
@@ -563,40 +503,24 @@ Options for hosting your catalog:
#### 3. Configure Your Environment
##### Option A: Catalog stack config file (recommended)
Add to `.specify/extension-catalogs.yml` in your project:
```yaml
catalogs:
- name: "org-approved"
url: "https://your-org.com/spec-kit/catalog.json"
priority: 1
install_allowed: true
```
Or use the CLI:
```bash
specify extension catalog add \
--name "org-approved" \
--install-allowed \
https://your-org.com/spec-kit/catalog.json
```
##### Option B: Environment variable (recommended for CI/CD, single-catalog)
##### Option A: Environment variable (recommended for CI/CD)
```bash
# In ~/.bashrc, ~/.zshrc, or CI pipeline
export SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json"
```
##### Option B: Per-project configuration
Create `.env` or set in your shell before running spec-kit commands:
```bash
SPECKIT_CATALOG_URL="https://your-org.com/spec-kit/catalog.json" specify extension search
```
#### 4. Verify Configuration
```bash
# List active catalogs
specify extension catalogs
# Search should now show your catalog's extensions
specify extension search

View File

@@ -868,7 +868,7 @@ Spec Kit uses two catalog files with different purposes:
- **Purpose**: Organization's curated catalog of approved extensions
- **Default State**: Empty by design - users populate with extensions they trust
- **Usage**: Primary catalog (priority 1, `install_allowed: true`) in the default stack
- **Usage**: Default catalog used by `specify extension` CLI commands
- **Control**: Organizations maintain their own fork/version for their teams
#### Community Reference Catalog (`catalog.community.json`)
@@ -879,16 +879,16 @@ Spec Kit uses two catalog files with different purposes:
- **Verification**: Community extensions may have `verified: false` initially
- **Status**: Active - open for community contributions
- **Submission**: Via Pull Request following the Extension Publishing Guide
- **Usage**: Secondary catalog (priority 2, `install_allowed: false`) in the default stack — discovery only
- **Usage**: Browse to discover extensions, then copy to your `catalog.json`
**How It Works (default stack):**
**How It Works:**
1. **Discover**: `specify extension search` searches both catalogs — community extensions appear automatically
2. **Review**: Evaluate community extensions for security, quality, and organizational fit
3. **Curate**: Copy approved entries from community catalog to your `catalog.json`, or add to `.specify/extension-catalogs.yml` with `install_allowed: true`
4. **Install**: Use `specify extension add <name>` — only allowed from `install_allowed: true` catalogs
1. **Discover**: Browse `catalog.community.json` to find available extensions
2. **Review**: Evaluate extensions for security, quality, and organizational fit
3. **Curate**: Copy approved extension entries from community catalog to your `catalog.json`
4. **Install**: Use `specify extension add <name>` (pulls from your curated catalog)
This approach gives organizations full control over which extensions can be installed while still providing community discoverability out of the box.
This approach gives organizations full control over which extensions are available to their teams while maintaining a shared community resource for discovery.
### Catalog Format
@@ -961,89 +961,30 @@ specify extension info jira
### Custom Catalogs
Spec Kit supports a **catalog stack** — an ordered list of catalogs that the CLI merges and searches across. This allows organizations to benefit from org-approved extensions, an internal catalog, and community discovery all at once.
**⚠️ FUTURE FEATURE - NOT YET IMPLEMENTED**
#### Catalog Stack Resolution
The active catalog stack is resolved in this order (first match wins):
1. **`SPECKIT_CATALOG_URL` environment variable** — single catalog replacing all defaults (backward compat)
2. **Project-level `.specify/extension-catalogs.yml`** — full control for the project
3. **User-level `~/.specify/extension-catalogs.yml`** — personal defaults
4. **Built-in default stack** — `catalog.json` (install_allowed: true) + `catalog.community.json` (install_allowed: false)
#### Default Built-in Stack
When no config file exists, the CLI uses:
| Priority | Catalog | install_allowed | Purpose |
|----------|---------|-----------------|---------|
| 1 | `catalog.json` (org-approved) | `true` | Extensions your org approves for installation |
| 2 | `catalog.community.json` (community) | `false` | Discovery only — browse but not install |
This means `specify extension search` surfaces community extensions out of the box, while `specify extension add` is still restricted to org-approved entries.
#### `.specify/extension-catalogs.yml` Config File
```yaml
catalogs:
- name: "org-approved"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
priority: 1 # Highest — only approved entries can be installed
install_allowed: true
- name: "internal"
url: "https://internal.company.com/spec-kit/catalog.json"
priority: 2
install_allowed: true
- name: "community"
url: "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
priority: 3 # Lowest — discovery only, not installable
install_allowed: false
```
A user-level equivalent lives at `~/.specify/extension-catalogs.yml`. When a project-level config is present, it takes full control and the built-in defaults are not applied.
#### Catalog CLI Commands
The following catalog management commands are proposed design concepts but are not yet available in the current implementation:
```bash
# List active catalogs with name, URL, priority, and install_allowed
# Add custom catalog (FUTURE - NOT AVAILABLE)
specify extension add-catalog https://internal.company.com/spec-kit/catalog.json
# Set as default (FUTURE - NOT AVAILABLE)
specify extension set-catalog --default https://internal.company.com/spec-kit/catalog.json
# List catalogs (FUTURE - NOT AVAILABLE)
specify extension catalogs
# Add a catalog (project-scoped)
specify extension catalog add --name "internal" --install-allowed \
https://internal.company.com/spec-kit/catalog.json
# Add a discovery-only catalog
specify extension catalog add --name "community" \
https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json
# Remove a catalog
specify extension catalog remove internal
# Show which catalog an extension came from
specify extension info jira
# → Source catalog: org-approved
```
#### Merge Conflict Resolution
**Proposed catalog priority** (future design):
When the same extension `id` appears in multiple catalogs, the higher-priority (lower priority number) catalog wins. Extensions from lower-priority catalogs with the same `id` are ignored.
1. Project-specific catalog (`.specify/extension-catalogs.yml`) - *not implemented*
2. User-level catalog (`~/.specify/extension-catalogs.yml`) - *not implemented*
3. Default GitHub catalog
#### `install_allowed: false` Behavior
#### Current Implementation: SPECKIT_CATALOG_URL
Extensions from discovery-only catalogs are shown in `specify extension search` results but cannot be installed directly:
```
⚠ 'linear' is available in the 'community' catalog but installation is not allowed from that catalog.
To enable installation, add 'linear' to an approved catalog (install_allowed: true) in .specify/extension-catalogs.yml.
```
#### `SPECKIT_CATALOG_URL` (Backward Compatibility)
The `SPECKIT_CATALOG_URL` environment variable still works — it is treated as a single `install_allowed: true` catalog, **replacing both defaults** for full backward compatibility:
**The currently available method** for using custom catalogs is the `SPECKIT_CATALOG_URL` environment variable:
```bash
# Point to your organization's catalog

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.1.7"
version = "0.1.12"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -1720,13 +1720,6 @@ extension_app = typer.Typer(
)
app.add_typer(extension_app, name="extension")
catalog_app = typer.Typer(
name="catalog",
help="Manage extension catalogs",
add_completion=False,
)
extension_app.add_typer(catalog_app, name="catalog")
def get_speckit_version() -> str:
"""Get current spec-kit version."""
@@ -1792,157 +1785,6 @@ def extension_list(
console.print(" [cyan]specify extension add <name>[/cyan]")
@extension_app.command("catalogs")
def extension_catalogs():
"""List all active extension catalogs."""
from .extensions import ExtensionCatalog, ValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
catalog = ExtensionCatalog(project_root)
try:
active_catalogs = catalog.get_active_catalogs()
except ValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
console.print(f" URL: {entry.url}")
console.print(f" Install: {install_str}")
console.print()
config_path = project_root / ".specify" / "extension-catalogs.yml"
if config_path.exists() and catalog._load_catalog_config(config_path) is not None:
console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]")
elif os.environ.get("SPECKIT_CATALOG_URL"):
console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/extension-catalogs.yml to customize.[/dim]"
)
@catalog_app.command("add")
def catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed",
),
):
"""Add a catalog to .specify/extension-catalogs.yml."""
from .extensions import ExtensionCatalog, ValidationError
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate URL
tmp_catalog = ExtensionCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except ValidationError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
config_path = specify_dir / "extension-catalogs.yml"
# Load existing config
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
config = {}
else:
config = {}
catalogs = config.get("catalogs", [])
# Check for duplicate name
for existing in catalogs:
if existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print("Use 'specify extension catalog remove' first, or choose a different name.")
raise typer.Exit(1)
catalogs.append({
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
})
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f" Priority: {priority}")
console.print(f"\nConfig saved to {config_path.relative_to(project_root)}")
@catalog_app.command("remove")
def catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/extension-catalogs.yml."""
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
config_path = specify_dir / "extension-catalogs.yml"
if not config_path.exists():
console.print("[red]Error:[/red] No catalog config found. Nothing to remove.")
raise typer.Exit(1)
try:
config = yaml.safe_load(config_path.read_text()) or {}
except Exception:
console.print("[red]Error:[/red] Failed to read catalog config.")
raise typer.Exit(1)
catalogs = config.get("catalogs", [])
original_count = len(catalogs)
catalogs = [c for c in catalogs if c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
@extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
@@ -2031,19 +1873,6 @@ def extension_add(
console.print(" specify extension search")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
console.print(
f"[red]Error:[/red] '{extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
)
console.print(
f"\nTo enable installation, add '{extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
)
raise typer.Exit(1)
# Download extension ZIP
console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...")
zip_path = catalog.download_extension(extension)
@@ -2188,15 +2017,6 @@ def extension_search(
tags_str = ", ".join(ext['tags'])
console.print(f" [dim]Tags:[/dim] {tags_str}")
# Source catalog
catalog_name = ext.get("_catalog_name", "")
install_allowed = ext.get("_install_allowed", True)
if catalog_name:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {catalog_name}")
else:
console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]")
# Stats
stats = []
if ext.get('downloads') is not None:
@@ -2210,15 +2030,8 @@ def extension_search(
if ext.get('repository'):
console.print(f" [dim]Repository:[/dim] {ext['repository']}")
# Install command (show warning if not installable)
if install_allowed:
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
" Add to an approved catalog with install_allowed: true, "
"or use: specify extension add --from <url>"
)
# Install command
console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}")
console.print()
except ExtensionError as e:
@@ -2267,12 +2080,6 @@ def extension_info(
# Author and License
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
# Source catalog
if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True)
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}")
console.print()
# Requirements
@@ -2329,21 +2136,12 @@ def extension_info(
# Installation status and command
is_installed = manager.registry.is_installed(ext_info['id'])
install_allowed = ext_info.get("_install_allowed", True)
if is_installed:
console.print("[green]✓ Installed[/green]")
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
elif install_allowed:
else:
console.print("[yellow]Not installed[/yellow]")
console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}")
else:
catalog_name = ext_info.get("_catalog_name", "community")
console.print("[yellow]Not installed[/yellow]")
console.print(
f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog "
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
f"with install_allowed: true to enable installation."
)
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] {e}")

View File

@@ -8,11 +8,9 @@ without bloating the core framework.
import json
import hashlib
import os
import tempfile
import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, List, Any
from datetime import datetime, timezone
@@ -38,15 +36,6 @@ class CompatibilityError(ExtensionError):
pass
@dataclass
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
class ExtensionManifest:
"""Represents and validates an extension manifest (extension.yml)."""
@@ -951,7 +940,6 @@ class ExtensionCatalog:
"""Manages extension catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
CACHE_DURATION = 3600 # 1 hour in seconds
def __init__(self, project_root: Path):
@@ -966,82 +954,43 @@ class ExtensionCatalog:
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
def get_catalog_url(self) -> str:
"""Get catalog URL from config or use default.
Args:
url: URL to validate
Checks in order:
1. SPECKIT_CATALOG_URL environment variable
2. Default catalog URL
Returns:
URL to fetch catalog from
Raises:
ValidationError: If URL is invalid or uses non-HTTPS scheme
ValidationError: If custom URL is invalid (non-HTTPS)
"""
import os
import sys
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
Args:
config_path: Path to extension-catalogs.yml
Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist
or contains no valid catalog entries.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text()) or {}
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
return None
entries: List[CatalogEntry] = []
for idx, item in enumerate(catalogs_data):
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
entries.append(CatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=int(item.get("priority", idx + 1)),
install_allowed=bool(item.get("install_allowed", True)),
))
entries.sort(key=lambda e: e.priority)
return entries if entries else None
except (yaml.YAMLError, OSError):
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
Resolution order:
1. SPECKIT_CATALOG_URL env var — single catalog replacing all defaults
2. Project-level .specify/extension-catalogs.yml
3. User-level ~/.specify/extension-catalogs.yml
4. Built-in default stack (org-approved + community)
Returns:
List of CatalogEntry objects sorted by priority (ascending)
Raises:
ValidationError: If a catalog URL is invalid
"""
import sys
# 1. SPECKIT_CATALOG_URL env var replaces all defaults for backward compat
# Environment variable override (useful for testing)
if env_value := os.environ.get("SPECKIT_CATALOG_URL"):
catalog_url = env_value.strip()
self._validate_catalog_url(catalog_url)
parsed = urlparse(catalog_url)
# Require HTTPS for security (prevent man-in-the-middle attacks)
# Allow http://localhost for local development/testing
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Invalid SPECKIT_CATALOG_URL: must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError(
"Invalid SPECKIT_CATALOG_URL: must be a valid URL with a host."
)
# Warn users when using a non-default catalog (once per instance)
if catalog_url != self.DEFAULT_CATALOG_URL:
if not getattr(self, "_non_default_catalog_warning_shown", False):
print(
@@ -1050,161 +999,11 @@ class ExtensionCatalog:
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True)]
# 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(project_config_path)
if catalogs is not None:
return catalogs
return catalog_url
# 3. User-level config
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(user_config_path)
if catalogs is not None:
return catalogs
# 4. Built-in default stack
return [
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="org-approved", priority=1, install_allowed=True),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False),
]
def get_catalog_url(self) -> str:
"""Get the primary catalog URL.
Returns the URL of the highest-priority catalog. Kept for backward
compatibility. Use get_active_catalogs() for full multi-catalog support.
Returns:
URL of the primary catalog
Raises:
ValidationError: If a catalog URL is invalid
"""
active = self.get_active_catalogs()
return active[0].url if active else self.DEFAULT_CATALOG_URL
def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
"""Fetch a single catalog with per-URL caching.
For the DEFAULT_CATALOG_URL, uses legacy cache files (self.cache_file /
self.cache_metadata_file) for backward compatibility. For all other URLs,
uses URL-hash-based cache files in self.cache_dir.
Args:
entry: CatalogEntry describing the catalog to fetch
force_refresh: If True, bypass cache
Returns:
Catalog data dictionary
Raises:
ExtensionError: If catalog cannot be fetched or has invalid format
"""
import urllib.request
import urllib.error
# Determine cache file paths (backward compat for default catalog)
if entry.url == self.DEFAULT_CATALOG_URL:
cache_file = self.cache_file
cache_meta_file = self.cache_metadata_file
is_valid = not force_refresh and self.is_cache_valid()
else:
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
cache_meta_file = self.cache_dir / f"catalog-{url_hash}-metadata.json"
is_valid = False
if not force_refresh and cache_file.exists() and cache_meta_file.exists():
try:
metadata = json.loads(cache_meta_file.read_text())
cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError):
# If metadata is invalid or missing expected fields, treat cache as invalid
pass
# Use cache if valid
if is_valid:
try:
return json.loads(cache_file.read_text())
except json.JSONDecodeError:
pass
# Fetch from network
try:
with urllib.request.urlopen(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError(f"Invalid catalog format from {entry.url}")
# Save to cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(catalog_data, indent=2))
cache_meta_file.write_text(json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}, indent=2))
return catalog_data
except urllib.error.URLError as e:
raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}")
except json.JSONDecodeError as e:
raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}")
def _get_merged_extensions(self, force_refresh: bool = False) -> List[Dict[str, Any]]:
"""Fetch and merge extensions from all active catalogs.
Higher-priority (lower priority number) catalogs win on conflicts
(same extension id in two catalogs). Each extension dict is annotated with:
- _catalog_name: name of the source catalog
- _install_allowed: whether installation is allowed from this catalog
Catalogs that fail to fetch are skipped. Raises ExtensionError only if
ALL catalogs fail.
Args:
force_refresh: If True, bypass all caches
Returns:
List of merged extension dicts
Raises:
ExtensionError: If all catalogs fail to fetch
"""
import sys
active_catalogs = self.get_active_catalogs()
merged: Dict[str, Dict[str, Any]] = {}
any_success = False
for catalog_entry in active_catalogs:
try:
catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh)
any_success = True
except ExtensionError as e:
print(
f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}",
file=sys.stderr,
)
continue
for ext_id, ext_data in catalog_data.get("extensions", {}).items():
if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = {
"id": ext_id,
**ext_data,
"_catalog_name": catalog_entry.name,
"_install_allowed": catalog_entry.install_allowed,
}
if not any_success and active_catalogs:
raise ExtensionError("Failed to fetch any extension catalog")
return list(merged.values())
# TODO: Support custom catalogs from .specify/extension-catalogs.yml
return self.DEFAULT_CATALOG_URL
def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid.
@@ -1281,7 +1080,7 @@ class ExtensionCatalog:
author: Optional[str] = None,
verified_only: bool = False,
) -> List[Dict[str, Any]]:
"""Search catalog for extensions across all active catalogs.
"""Search catalog for extensions.
Args:
query: Search query (searches name, description, tags)
@@ -1290,16 +1089,14 @@ class ExtensionCatalog:
verified_only: If True, show only verified extensions
Returns:
List of matching extension metadata, each annotated with
``_catalog_name`` and ``_install_allowed`` from its source catalog.
List of matching extension metadata
"""
all_extensions = self._get_merged_extensions()
catalog = self.fetch_catalog()
extensions = catalog.get("extensions", {})
results = []
for ext_data in all_extensions:
ext_id = ext_data["id"]
for ext_id, ext_data in extensions.items():
# Apply filters
if verified_only and not ext_data.get("verified", False):
continue
@@ -1325,26 +1122,25 @@ class ExtensionCatalog:
if query_lower not in searchable_text:
continue
results.append(ext_data)
results.append({"id": ext_id, **ext_data})
return results
def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information about a specific extension.
Searches all active catalogs in priority order.
Args:
extension_id: ID of the extension
Returns:
Extension metadata (annotated with ``_catalog_name`` and
``_install_allowed``) or None if not found.
Extension metadata or None if not found
"""
all_extensions = self._get_merged_extensions()
for ext_data in all_extensions:
if ext_data["id"] == extension_id:
return ext_data
catalog = self.fetch_catalog()
extensions = catalog.get("extensions", {})
if extension_id in extensions:
return {"id": extension_id, **extensions[extension_id]}
return None
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
@@ -1404,18 +1200,11 @@ class ExtensionCatalog:
raise ExtensionError(f"Failed to save extension ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
"""Clear the catalog cache."""
if self.cache_file.exists():
self.cache_file.unlink()
if self.cache_metadata_file.exists():
self.cache_metadata_file.unlink()
# Also clear any per-URL hash-based cache files
if self.cache_dir.exists():
for extra_cache in self.cache_dir.glob("catalog-*.json"):
if extra_cache != self.cache_file:
extra_cache.unlink(missing_ok=True)
for extra_meta in self.cache_dir.glob("catalog-*-metadata.json"):
extra_meta.unlink(missing_ok=True)
class ConfigManager:

View File

@@ -6,7 +6,6 @@ Tests cover:
- Extension registry operations
- Extension manager installation/removal
- Command registration
- Catalog stack (multi-catalog support)
"""
import pytest
@@ -17,7 +16,6 @@ from pathlib import Path
from datetime import datetime, timezone
from specify_cli.extensions import (
CatalogEntry,
ExtensionManifest,
ExtensionRegistry,
ExtensionManager,
@@ -736,29 +734,10 @@ class TestExtensionCatalog:
def test_search_all_extensions(self, temp_dir):
"""Test searching all extensions without filters."""
import yaml as yaml_module
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
# Use a single-catalog config so community extensions don't interfere
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump(
{
"catalogs": [
{
"name": "test-catalog",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
]
},
f,
)
catalog = ExtensionCatalog(project_dir)
# Create mock catalog
@@ -1008,373 +987,3 @@ class TestExtensionCatalog:
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
# ===== CatalogEntry Tests =====
class TestCatalogEntry:
"""Test CatalogEntry dataclass."""
def test_catalog_entry_creation(self):
"""Test creating a CatalogEntry."""
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
# ===== Catalog Stack Tests =====
class TestCatalogStack:
"""Test multi-catalog stack support."""
def _make_project(self, temp_dir: Path) -> Path:
"""Create a minimal spec-kit project directory."""
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return project_dir
def _write_catalog_config(self, project_dir: Path, catalogs: list) -> None:
"""Write extension-catalogs.yml to project .specify dir."""
import yaml as yaml_module
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": catalogs}, f)
def _write_valid_cache(
self, catalog: ExtensionCatalog, extensions: dict, url: str = "http://test.com"
) -> None:
"""Populate the primary cache file with mock extension data."""
catalog_data = {"schema_version": "1.0", "extensions": extensions}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(catalog_data))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": url,
}
)
)
# --- get_active_catalogs ---
def test_default_stack(self, temp_dir):
"""Default stack includes org-approved and community catalogs."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
assert entries[0].name == "org-approved"
assert entries[0].priority == 1
assert entries[0].install_allowed is True
assert entries[1].url == ExtensionCatalog.COMMUNITY_CATALOG_URL
assert entries[1].name == "community"
assert entries[1].priority == 2
assert entries[1].install_allowed is False
def test_env_var_overrides_default_stack(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL replaces the entire default stack."""
project_dir = self._make_project(temp_dir)
custom_url = "https://example.com/catalog.json"
monkeypatch.setenv("SPECKIT_CATALOG_URL", custom_url)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == custom_url
assert entries[0].install_allowed is True
def test_env_var_invalid_url_raises(self, temp_dir, monkeypatch):
"""SPECKIT_CATALOG_URL with http:// (non-localhost) raises ValidationError."""
project_dir = self._make_project(temp_dir)
monkeypatch.setenv("SPECKIT_CATALOG_URL", "http://example.com/catalog.json")
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_project_config_overrides_defaults(self, temp_dir):
"""Project-level extension-catalogs.yml overrides default stack."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "custom",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "https://example.com/catalog.json"
assert entries[0].name == "custom"
def test_project_config_sorted_by_priority(self, temp_dir):
"""Catalog entries are sorted by priority (ascending)."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "secondary",
"url": "https://example.com/secondary.json",
"priority": 5,
"install_allowed": False,
},
{
"name": "primary",
"url": "https://example.com/primary.json",
"priority": 1,
"install_allowed": True,
},
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 2
assert entries[0].name == "primary"
assert entries[1].name == "secondary"
def test_project_config_invalid_url_raises(self, temp_dir):
"""Project config with HTTP (non-localhost) URL raises ValidationError."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "bad",
"url": "http://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError, match="HTTPS"):
catalog.get_active_catalogs()
def test_empty_project_config_falls_back_to_defaults(self, temp_dir):
"""Empty catalogs list in config falls back to default stack."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
with open(config_path, "w") as f:
yaml_module.dump({"catalogs": []}, f)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
# Falls back to default stack
assert len(entries) == 2
assert entries[0].url == ExtensionCatalog.DEFAULT_CATALOG_URL
# --- _load_catalog_config ---
def test_load_catalog_config_missing_file(self, temp_dir):
"""Returns None when config file doesn't exist."""
project_dir = self._make_project(temp_dir)
catalog = ExtensionCatalog(project_dir)
result = catalog._load_catalog_config(project_dir / ".specify" / "nonexistent.yml")
assert result is None
def test_load_catalog_config_localhost_allowed(self, temp_dir):
"""Localhost HTTP URLs are allowed in config."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "local",
"url": "http://localhost:8000/catalog.json",
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
entries = catalog.get_active_catalogs()
assert len(entries) == 1
assert entries[0].url == "http://localhost:8000/catalog.json"
# --- Merge conflict resolution ---
def test_merge_conflict_higher_priority_wins(self, temp_dir):
"""When same extension id is in two catalogs, higher priority wins."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
# Write project config with two catalogs
self._write_catalog_config(
project_dir,
[
{
"name": "primary",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
},
{
"name": "secondary",
"url": ExtensionCatalog.COMMUNITY_CATALOG_URL,
"priority": 2,
"install_allowed": False,
},
],
)
catalog = ExtensionCatalog(project_dir)
# Write primary cache with jira v2.0.0
primary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "2.0.0",
"description": "Primary Jira",
}
},
}
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(primary_data))
catalog.cache_metadata_file.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": "http://test.com"})
)
# Write secondary cache (URL-hash-based) with jira v1.0.0 (should lose)
import hashlib
url_hash = hashlib.sha256(ExtensionCatalog.COMMUNITY_CATALOG_URL.encode()).hexdigest()[:16]
secondary_cache = catalog.cache_dir / f"catalog-{url_hash}.json"
secondary_meta = catalog.cache_dir / f"catalog-{url_hash}-metadata.json"
secondary_data = {
"schema_version": "1.0",
"extensions": {
"jira": {
"name": "Jira Integration Community",
"id": "jira",
"version": "1.0.0",
"description": "Community Jira",
},
"linear": {
"name": "Linear",
"id": "linear",
"version": "0.9.0",
"description": "Linear from secondary",
},
},
}
secondary_cache.write_text(json.dumps(secondary_data))
secondary_meta.write_text(
json.dumps({"cached_at": datetime.now(timezone.utc).isoformat(), "catalog_url": ExtensionCatalog.COMMUNITY_CATALOG_URL})
)
results = catalog.search()
jira_results = [r for r in results if r["id"] == "jira"]
assert len(jira_results) == 1
# Primary catalog wins
assert jira_results[0]["version"] == "2.0.0"
assert jira_results[0]["_catalog_name"] == "primary"
assert jira_results[0]["_install_allowed"] is True
# linear comes from secondary
linear_results = [r for r in results if r["id"] == "linear"]
assert len(linear_results) == 1
assert linear_results[0]["_catalog_name"] == "secondary"
assert linear_results[0]["_install_allowed"] is False
def test_install_allowed_false_from_get_extension_info(self, temp_dir):
"""get_extension_info includes _install_allowed from source catalog."""
project_dir = self._make_project(temp_dir)
# Single catalog that is install_allowed=False
self._write_catalog_config(
project_dir,
[
{
"name": "discovery",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": False,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
info = catalog.get_extension_info("jira")
assert info is not None
assert info["_install_allowed"] is False
assert info["_catalog_name"] == "discovery"
def test_search_results_include_catalog_metadata(self, temp_dir):
"""Search results include _catalog_name and _install_allowed."""
project_dir = self._make_project(temp_dir)
self._write_catalog_config(
project_dir,
[
{
"name": "org",
"url": ExtensionCatalog.DEFAULT_CATALOG_URL,
"priority": 1,
"install_allowed": True,
}
],
)
catalog = ExtensionCatalog(project_dir)
self._write_valid_cache(
catalog,
{
"jira": {
"name": "Jira Integration",
"id": "jira",
"version": "1.0.0",
"description": "Jira integration",
}
},
)
results = catalog.search()
assert len(results) == 1
assert results[0]["_catalog_name"] == "org"
assert results[0]["_install_allowed"] is True