diff --git a/.github/workflows/community-smoke.yml b/.github/workflows/community-smoke.yml index e2c64e78d8..09310fdd40 100644 --- a/.github/workflows/community-smoke.yml +++ b/.github/workflows/community-smoke.yml @@ -43,7 +43,50 @@ jobs: test -f .specify/extensions/preview/extension.yml test -f .specify/extensions/repository-governance/extension.yml test -f .specify/presets/workflow-preset/preset.yml + test -f .specify/presets/workflow-preset/templates/behavior/behavior-scenarios-draft.json + test -f .specify/presets/workflow-preset/templates/behavior/behavior-testability-checklist.md + test -f .specify/presets/workflow-preset/templates/behavior/bdd-draft.feature + test -f .specify/presets/workflow-preset/templates/behavior/bdd-contract.feature + test -f .specify/presets/workflow-preset/templates/behavior/uif-intent.json + test -f .specify/presets/workflow-preset/templates/behavior/uif-expected.json + test -f .specify/presets/workflow-preset/templates/behavior/scenario-instances.json + test -f .specify/presets/workflow-preset/templates/behavior/data-fixtures-intent.json + test -f .specify/presets/workflow-preset/templates/behavior/data-fixtures.json + test -f .specify/presets/workflow-preset/templates/behavior/assertions.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.scenarios.draft.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.uif.intent.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.data-fixtures.intent.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.uif.expected.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.scenario-instances.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.data-fixtures.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.behavior.assertions.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.implement.manifest.v1.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.implement.handoff.v2.schema.json + test -f .specify/presets/workflow-preset/schemas/speckit.implement.receipt.v1.schema.json + test -f .specify/presets/workflow-preset/.composed/speckit.specify.md + test -f .specify/presets/workflow-preset/.composed/speckit.clarify.md + test -f .specify/presets/workflow-preset/.composed/speckit.checklist.md + test -f .specify/presets/workflow-preset/.composed/speckit.analyze.md + test -f .specify/presets/workflow-preset/.composed/speckit.plan.md + test -f .specify/presets/workflow-preset/.composed/speckit.tasks.md test -f .claude/skills/speckit-arch-generate/SKILL.md test -f .claude/skills/speckit-preview-html/SKILL.md test -f .claude/skills/speckit-repository-governance-refresh/SKILL.md + test -f .claude/skills/speckit-specify/SKILL.md + test -f .claude/skills/speckit-clarify/SKILL.md + test -f .claude/skills/speckit-checklist/SKILL.md + test -f .claude/skills/speckit-analyze/SKILL.md + test -f .claude/skills/speckit-plan/SKILL.md + test -f .claude/skills/speckit-tasks/SKILL.md + test -f .claude/skills/speckit-implement/SKILL.md + + grep -q "Phase 0 Behavior Projection" .claude/skills/speckit-plan/SKILL.md + grep -q "Test Strategy Derivation" .claude/skills/speckit-tasks/SKILL.md + grep -q "Core Agent" .claude/skills/speckit-implement/SKILL.md + grep -q "Vertical Planner Agent" .claude/skills/speckit-implement/SKILL.md + grep -q "Worker Agent" .claude/skills/speckit-implement/SKILL.md + + /tmp/specify-community-smoke-venv/bin/specify preset resolve plan-template | tee resolve-plan-template.txt + grep -q "workflow-preset" resolve-plan-template.txt + grep -q "Composition chain" resolve-plan-template.txt diff --git a/.github/workflows/workflow-preset-integration.yml b/.github/workflows/workflow-preset-integration.yml new file mode 100644 index 0000000000..af540e910d --- /dev/null +++ b/.github/workflows/workflow-preset-integration.yml @@ -0,0 +1,182 @@ +name: Workflow Preset Integration + +permissions: + contents: read + +on: + repository_dispatch: + types: ["workflow-preset-release"] + workflow_dispatch: + inputs: + preset_version: + description: "Workflow preset version to validate" + required: true + preset_download_url: + description: "Optional workflow preset release ZIP URL" + required: false + require_bundled_version_match: + description: "Require bundled preset version to match preset_version" + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.event.client_payload.preset_version || github.event.client_payload.version || inputs.preset_version || 'unknown-version' }}-${{ github.run_id }} + cancel-in-progress: false + +jobs: + external-release-install: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + + - name: Install CLI from checkout + run: | + set -euo pipefail + uv venv /tmp/specify-workflow-preset-venv + uv pip install --python /tmp/specify-workflow-preset-venv/bin/python . + + - name: Resolve workflow preset release + id: release + env: + EVENT_PRESET_VERSION: ${{ github.event.client_payload.preset_version || github.event.client_payload.version || inputs.preset_version }} + EVENT_PRESET_DOWNLOAD_URL: ${{ github.event.client_payload.preset_download_url || github.event.client_payload.download_url || inputs.preset_download_url }} + run: | + set -euo pipefail + write_github_output() { + local name="$1" + local value="$2" + local delimiter + delimiter="ghadelim_${RANDOM}_${RANDOM}_$(date +%s%N)" + while printf '%s\n' "$value" | grep -Fxq "$delimiter"; do + delimiter="ghadelim_${RANDOM}_${RANDOM}_$(date +%s%N)" + done + + { + printf '%s<<%s\n' "$name" "$delimiter" + printf '%s\n' "$value" + printf '%s\n' "$delimiter" + } >> "$GITHUB_OUTPUT" + } + + preset_version="${EVENT_PRESET_VERSION}" + if [ -z "$preset_version" ]; then + echo "preset_version is required from repository_dispatch payload or workflow_dispatch input" >&2 + exit 1 + fi + if ! printf '%s\n' "$preset_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "preset_version must be a bare semantic version like 1.2.3" >&2 + exit 1 + fi + + preset_download_url="${EVENT_PRESET_DOWNLOAD_URL}" + if [ -z "$preset_download_url" ]; then + preset_download_url="https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v${preset_version}/spec-kit-workflow-preset-v${preset_version}.zip" + fi + + write_github_output "version" "$preset_version" + write_github_output "download_url" "$preset_download_url" + + - name: Install and verify external workflow preset + env: + PRESET_VERSION: ${{ steps.release.outputs.version }} + PRESET_DOWNLOAD_URL: ${{ steps.release.outputs.download_url }} + run: | + set -euo pipefail + project_dir="$(mktemp -d)" + cd "$project_dir" + + /tmp/specify-workflow-preset-venv/bin/specify init --here --ai claude --script sh --ignore-agent-tools + /tmp/specify-workflow-preset-venv/bin/specify preset remove workflow-preset + /tmp/specify-workflow-preset-venv/bin/specify preset add --from "$PRESET_DOWNLOAD_URL" + + /tmp/specify-workflow-preset-venv/bin/specify preset info workflow-preset | tee preset-info.txt + grep -F "ID: workflow-preset" preset-info.txt + grep -F "Version: $PRESET_VERSION" preset-info.txt + grep -F "Status: installed" preset-info.txt + + /tmp/specify-workflow-preset-venv/bin/specify preset resolve plan-template | tee preset-resolve-plan-template.txt + grep -F "plan-template" preset-resolve-plan-template.txt + grep -F "workflow-preset" preset-resolve-plan-template.txt + + test -f .specify/presets/workflow-preset/preset.yml + test -f .specify/presets/workflow-preset/templates/plan-template.md + test -f .specify/presets/workflow-preset/templates/tasks-template.md + test -f .specify/presets/workflow-preset/commands/speckit.plan.md + test -f .specify/presets/workflow-preset/commands/speckit.tasks.md + test -f .specify/presets/workflow-preset/commands/speckit.implement.md + + test -f .claude/skills/speckit-plan/SKILL.md + test -f .claude/skills/speckit-tasks/SKILL.md + test -f .claude/skills/speckit-implement/SKILL.md + grep -F "Core Agent" .claude/skills/speckit-implement/SKILL.md + grep -F "Vertical Planner Agent" .claude/skills/speckit-implement/SKILL.md + grep -F "Worker Agent" .claude/skills/speckit-implement/SKILL.md + + bundled-regression: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + set -euo pipefail + uv sync --all-extras --dev + + - name: Check bundled workflow preset version + env: + EVENT_PRESET_VERSION: ${{ github.event.client_payload.preset_version || github.event.client_payload.version || inputs.preset_version }} + REQUIRE_BUNDLED_VERSION_MATCH: ${{ github.event.client_payload.require_bundled_version_match || inputs.require_bundled_version_match || 'false' }} + run: | + set -euo pipefail + if [ "$REQUIRE_BUNDLED_VERSION_MATCH" != "true" ]; then + echo "Bundled preset version match check skipped." + exit 0 + fi + if [ -z "$EVENT_PRESET_VERSION" ]; then + echo "preset_version is required when require_bundled_version_match is true" >&2 + exit 1 + fi + if ! printf '%s\n' "$EVENT_PRESET_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "preset_version must be a bare semantic version like 1.2.3" >&2 + exit 1 + fi + + bundled_version="$(uv run python -c 'import yaml; data = yaml.safe_load(open("presets/workflow-preset/preset.yml", encoding="utf-8")); print(data["preset"]["version"])')" + + if [ "$bundled_version" != "$EVENT_PRESET_VERSION" ]; then + echo "Bundled workflow-preset version '$bundled_version' does not match '$EVENT_PRESET_VERSION'" >&2 + exit 1 + fi + + - name: Run targeted CLI integration tests + run: | + set -euo pipefail + uv run pytest tests/integrations/test_cli.py -k "community_extensions_and_workflow_preset_auto_installed or workflow_preset_registers" -v + + - name: Run targeted preset tests + run: | + set -euo pipefail + uv run pytest tests/test_presets.py -k "workflow_preset or bundled_preset" -v diff --git a/tests/integrations/community_defaults.py b/tests/integrations/community_defaults.py index 6b3ac68ebb..a7fcca2b40 100644 --- a/tests/integrations/community_defaults.py +++ b/tests/integrations/community_defaults.py @@ -17,6 +17,15 @@ "speckit.repository-governance.refresh", ) DEFAULT_PRESET_ID = "workflow-preset" +DEFAULT_PRESET_COMMANDS = ( + "speckit.specify", + "speckit.clarify", + "speckit.checklist", + "speckit.analyze", + "speckit.plan", + "speckit.tasks", + "speckit.implement", +) def _copied_extension_files(extension_id: str) -> list[str]: @@ -91,6 +100,38 @@ def _registered_extension_command_files( return files +def _registered_workflow_preset_command_files( + integration_key: str, + *, + skills_mode: bool = False, + commands_dir: str | None = None, +) -> list[str]: + integration = get_integration(integration_key) + config = dict(integration.registrar_config) + if skills_mode: + config["dir"] = f"{integration.config['folder']}skills" + config["extension"] = "/SKILL.md" + if commands_dir is not None: + config["dir"] = commands_dir + command_dir = Path(config["dir"]) + extension = config["extension"] + + files: list[str] = [] + for command_name in DEFAULT_PRESET_COMMANDS: + output_name = CommandRegistrar._compute_output_name( + integration_key, command_name, config + ) + if extension == "/SKILL.md": + files.append((command_dir / output_name / "SKILL.md").as_posix()) + else: + files.append((command_dir / f"{output_name}{extension}").as_posix()) + + if integration_key == "copilot" and not skills_mode: + files.append(f".github/prompts/{command_name}.prompt.md") + + return files + + def bundled_community_default_files( integration_key: str, *, @@ -115,4 +156,6 @@ def bundled_community_default_files( commands_dir=commands_dir, ) ) - return sorted(files) + # workflow-preset wraps/replaces core commands whose output paths are already + # accounted for by each integration's base command inventory. + return sorted(set(files)) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index e8b1918053..6a66fc4d84 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -953,6 +953,61 @@ def test_community_extensions_and_workflow_preset_auto_installed(self, tmp_path) preset_registry = json.loads((project / ".specify" / "presets" / ".registry").read_text()) assert "workflow-preset" in preset_registry["presets"] + def test_workflow_preset_registers_commands_and_composes_wrappers(self, tmp_path): + """workflow-preset registers commands and composes wrapped core templates.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "workflow-preset-registration" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + preset_dir = project / ".specify" / "presets" / "workflow-preset" + assert (preset_dir / "preset.yml").exists() + assert (preset_dir / "schemas" / "speckit.implement.handoff.v2.schema.json").exists() + assert (preset_dir / "templates" / "behavior" / "bdd-draft.feature").exists() + assert (preset_dir / ".composed" / "speckit.plan.md").exists() + assert (preset_dir / ".composed" / "speckit.tasks.md").exists() + assert (preset_dir / ".composed" / "speckit.analyze.md").exists() + + preset_registry = json.loads((project / ".specify" / "presets" / ".registry").read_text()) + workflow_entry = preset_registry["presets"]["workflow-preset"] + expected_preset_commands = { + "speckit.specify", + "speckit.clarify", + "speckit.checklist", + "speckit.analyze", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + } + assert set(workflow_entry["registered_commands"]["claude"]) == expected_preset_commands + + plan_skill = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + tasks_skill = project / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" + implement_skill = project / ".claude" / "skills" / "speckit-implement" / "SKILL.md" + for skill_path in (plan_skill, tasks_skill, implement_skill): + assert skill_path.exists(), f"{skill_path} was not registered" + + assert "Phase 0 Behavior Projection" in plan_skill.read_text(encoding="utf-8") + assert "test strategy derivation" in tasks_skill.read_text(encoding="utf-8").lower() + implement_text = implement_skill.read_text(encoding="utf-8") + assert "Core Agent" in implement_text + assert "Vertical Planner Agent" in implement_text + assert "Worker Agent" in implement_text + assert "speckit.implement.handoff.v2" in implement_text + def test_no_git_keeps_community_defaults(self, tmp_path): """--no-git skips only git; bundled community defaults still install.""" from typer.testing import CliRunner diff --git a/tests/test_presets.py b/tests/test_presets.py index 5faf90960c..9c20c7ecb2 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3477,6 +3477,48 @@ def test_bundled_preset_in_catalog(self): assert catalog["presets"][preset_id]["bundled"] is True assert "download_url" not in catalog["presets"][preset_id] + def test_workflow_preset_catalog_matches_manifest(self): + """workflow-preset catalog entry matches bundled preset manifest.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + manifest_path = Path(__file__).parent.parent / "presets" / "workflow-preset" / "preset.yml" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + + entry = catalog["presets"]["workflow-preset"] + provided = manifest["provides"]["templates"] + command_count = sum(1 for item in provided if item["type"] == "command") + template_count = sum(1 for item in provided if item["type"] == "template") + + assert entry["bundled"] is True + assert entry["version"] == manifest["preset"]["version"] + assert entry["repository"] == manifest["preset"]["repository"] + assert entry["requires"]["speckit_version"] == manifest["requires"]["speckit_version"] + assert entry["provides"]["commands"] == command_count + assert entry["provides"]["templates"] == template_count + assert entry["tags"] == manifest["tags"] + + def test_workflow_preset_integration_release_payload_contract(self): + """Workflow preset release dispatch contract stays aligned with preset repo.""" + workflow_path = ( + Path(__file__).parent.parent + / ".github" + / "workflows" + / "workflow-preset-integration.yml" + ) + workflow_text = workflow_path.read_text(encoding="utf-8") + workflow = yaml.safe_load(workflow_text) + on_config = workflow.get("on", workflow.get(True)) + + assert on_config["repository_dispatch"]["types"] == ["workflow-preset-release"] + assert "github.event.client_payload.preset_version" in workflow_text + assert "github.event.client_payload.preset_download_url" in workflow_text + assert ( + "releases/download/v${preset_version}/" + "spec-kit-workflow-preset-v${preset_version}.zip" + ) in workflow_text + assert "^[0-9]+\\.[0-9]+\\.[0-9]+$" in workflow_text + assert "/workflow-preset.zip" not in workflow_text + def test_bundled_preset_download_raises_error(self, project_dir): """download_pack raises PresetError for bundled presets without download_url.""" catalog = PresetCatalog(project_dir)