From 74c57f9db8282359cb5d1713bf70ce92331645a0 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 10 Sep 2026 06:59:36 +0200 Subject: [PATCH 1/5] feat(bundles): add first-party bugfix and assess bundles with bundled workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #4495. Two first-party bundles pairing the bug and assess extensions with orchestrated, resumable pipelines: - workflows/bugfix: bug assess -> human review gate -> bug fix -> bug test - workflows/assess: intake -> research -> define -> shape -> decide -> verdict review gate (specify handoff stays manual) Both workflows are registered in workflows/catalog.json (bundled: true) and force-included in the wheel's core_pack like speckit. The bundles live in bundles/{bugfix,assess} and are listed in a new repo-shipped first-party catalog bundles/catalog.json (verified: true). The reserved builtin://default catalog source now resolves from that catalog — online from the repository, offline from the packaged wheel snapshot — so `specify bundle add bugfix|assess` resolves by id. Also adds `bundle add` as an alias for `bundle install`. Tests: workflow validation and structure guards, wheel force-include contract (mirrors test_wheel_bundled_presets.py), first-party catalog/manifest/pin consistency, and workflows/catalog.json <-> workflow.yml id/version/url consistency. Assisted-by: opencode (model: glm-5.3, autonomous) --- bundles/assess/README.md | 38 +++++ bundles/assess/bundle.yml | 25 +++ bundles/bugfix/README.md | 36 +++++ bundles/bugfix/bundle.yml | 25 +++ bundles/catalog.json | 51 ++++++ docs/reference/bundles.md | 23 ++- pyproject.toml | 7 +- src/specify_cli/bundler/services/adapters.py | 45 +++--- src/specify_cli/commands/bundle/__init__.py | 14 ++ tests/contract/test_bundle_cli.py | 22 ++- ...t_firstparty_bundle_catalog_consistency.py | 153 ++++++++++++++++++ .../contract/test_wheel_bundled_workflows.py | 52 ++++++ tests/integration/test_bundler_offline.py | 16 +- tests/unit/test_bundler_adapters.py | 40 +++++ .../test_bundled_bugfix_assess_workflows.py | 60 +++++++ workflows/README.md | 6 +- workflows/assess/workflow.yml | 62 +++++++ workflows/bugfix/workflow.yml | 50 ++++++ workflows/catalog.json | 25 ++- 19 files changed, 715 insertions(+), 35 deletions(-) create mode 100644 bundles/assess/README.md create mode 100644 bundles/assess/bundle.yml create mode 100644 bundles/bugfix/README.md create mode 100644 bundles/bugfix/bundle.yml create mode 100644 bundles/catalog.json create mode 100644 tests/contract/test_firstparty_bundle_catalog_consistency.py create mode 100644 tests/contract/test_wheel_bundled_workflows.py create mode 100644 tests/workflows/test_bundled_bugfix_assess_workflows.py create mode 100644 workflows/assess/workflow.yml create mode 100644 workflows/bugfix/workflow.yml diff --git a/bundles/assess/README.md b/bundles/assess/README.md new file mode 100644 index 0000000000..85aedf6e86 --- /dev/null +++ b/bundles/assess/README.md @@ -0,0 +1,38 @@ +# Idea Assessment Bundle + +A first-party GitHub Spec Kit bundle that installs an idea-triage pipeline before committing to Spec-Driven Development. + +## What it provides + +- **Assess extension** (`extensions/assess`) — the `speckit.assess.intake`, `speckit.assess.research`, `speckit.assess.define`, `speckit.assess.shape`, and `speckit.assess.decide` commands. +- **Assess workflow** (`workflows/assess`) — a guided, resumable pipeline: + 1. `intake` the raw idea. + 2. `research` the evidence. + 3. `define` the problem. + 4. `shape` the concept. + 5. `decide` the verdict. + 6. `review-verdict` gate — approve to complete the assessment; reject to abort. A `go` verdict is then handed off manually to `/speckit.specify`. + +## Install + +```bash +specify bundle install assess +# or +specify bundle add assess +``` + +## Run the workflow + +```bash +specify workflow run assess +``` + +You will be prompted for an idea and a slug. The slug is used as the working directory under `.specify/assessments//` for all artifacts. + +## Remove + +```bash +specify bundle remove assess +``` + +Removing the bundle uninstalls both the workflow and the extension only if no other installed bundle still depends on them (FR-022). diff --git a/bundles/assess/bundle.yml b/bundles/assess/bundle.yml new file mode 100644 index 0000000000..d788a5e7de --- /dev/null +++ b/bundles/assess/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + role: "developer" + description: "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to the specify command." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "assess" + version: "1.0.0" + workflows: + - id: "assess" + version: "1.0.0" + +tags: ["assessment", "discovery", "triage", "product"] diff --git a/bundles/bugfix/README.md b/bundles/bugfix/README.md new file mode 100644 index 0000000000..2f7805591a --- /dev/null +++ b/bundles/bugfix/README.md @@ -0,0 +1,36 @@ +# Bug Fix Bundle + +A first-party GitHub Spec Kit bundle that installs an orchestrated bug-fixing pipeline. + +## What it provides + +- **Bug extension** (`extensions/bug`) — the `speckit.bug.assess`, `speckit.bug.fix`, and `speckit.bug.test` commands. +- **Bugfix workflow** (`workflows/bugfix`) — a guided, resumable pipeline: + 1. `assess` the bug report. + 2. `review-assessment` gate — approve to proceed, reject to abort. + 3. `fix` the bug. + 4. `test` the fix. + +## Install + +```bash +specify bundle install bugfix +# or +specify bundle add bugfix +``` + +## Run the workflow + +```bash +specify workflow run bugfix +``` + +You will be prompted for a bug report and a slug. The slug is used as the working directory under `.specify/bugs//` for all artifacts. + +## Remove + +```bash +specify bundle remove bugfix +``` + +Removing the bundle uninstalls both the workflow and the extension only if no other installed bundle still depends on them (FR-022). diff --git a/bundles/bugfix/bundle.yml b/bundles/bugfix/bundle.yml new file mode 100644 index 0000000000..8c81f76679 --- /dev/null +++ b/bundles/bugfix/bundle.yml @@ -0,0 +1,25 @@ +schema_version: "1.0" + +bundle: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + role: "developer" + description: "Orchestrated bug triage: assess a bug report, review the assessment behind a human gate, apply the fix, and verify it with tests." + author: "GitHub" + license: "MIT" + +requires: + speckit_version: ">=0.9.0" + tools: [] + mcp: [] + +provides: + extensions: + - id: "bug" + version: "1.0.0" + workflows: + - id: "bugfix" + version: "1.0.0" + +tags: ["bug", "triage", "workflow", "qa"] diff --git a/bundles/catalog.json b/bundles/catalog.json new file mode 100644 index 0000000000..789bdd149b --- /dev/null +++ b/bundles/catalog.json @@ -0,0 +1,51 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-09-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.json", + "bundles": { + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "version": "1.0.0", + "role": "developer", + "description": "Orchestrated bug triage: assess a bug report, review behind a human gate, apply the fix, and verify with tests.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/bugfix/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["bug", "triage", "workflow", "qa"], + "verified": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "version": "1.0.0", + "role": "developer", + "description": "Idea triage before Spec-Driven Development: intake, research, define, shape, decide with a verdict review gate; surviving ideas hand off manually to specify.", + "author": "GitHub", + "license": "MIT", + "download_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/assess/bundle.yml", + "repository": "https://github.com/github/spec-kit", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 0, + "steps": 0, + "workflows": 1 + }, + "tags": ["assessment", "discovery", "triage", "product"], + "verified": true + } + } +} diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 2bd33c960b..6223433888 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -4,6 +4,24 @@ Bundles compose existing Spec Kit components — extensions, presets, workflows, A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks for the single cross-bundle conflict point (the active integration), and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later. +## First-party Bundles + +Spec Kit ships a first-party bundle catalog in `bundles/catalog.json`. These bundles are curated, marked `verified: true`, and resolve through the built-in `builtin://default` catalog source. + +| Bundle | Role | Components | Use case | +| --------- | ----------- | ----------------------------------------------------- | --------------------------------------- | +| `bugfix` | `developer` | `bug` extension + `bugfix` workflow | Guided assess → gate → fix → test | +| `assess` | `developer` | `assess` extension + `assess` workflow | Idea triage before Spec-Driven Development | + +Install a first-party bundle the same way you install any bundle (`add` is an alias for `install`): + +```bash +specify bundle install bugfix +specify bundle add assess +``` + +The first-party catalog is fetched from the repository online and falls back to the packaged wheel snapshot offline so discovery works without network access. The bundle manifest itself still resolves from its `download_url`, so installing a first-party bundle requires network today; fully offline bundle installation is tracked as follow-up work. + ## Search Available Bundles ```bash @@ -128,7 +146,10 @@ If your bundle references components from non-default catalogs, document those c ## Manage Catalog Sources -Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). +Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). The built-in sources are: + +- `builtin://default` — first-party bundles shipped in `bundles/catalog.json` (`bugfix`, `assess`, ...), install-allowed. +- `builtin://community` — community submissions in `bundles/catalog.community.json`, discovery-only. ### List the Catalog Stack diff --git a/pyproject.toml b/pyproject.toml index 06e8f5df56..b1573aece9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,12 +45,15 @@ packages = ["src/specify_cli"] "extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context" "extensions/assess" = "specify_cli/core_pack/extensions/assess" "extensions/bug" = "specify_cli/core_pack/extensions/bug" -# Bundled workflows (auto-installed during `specify init`) +# Bundled workflows (auto-installed during `specify init` or via first-party bundles) "workflows/speckit" = "specify_cli/core_pack/workflows/speckit" +"workflows/bugfix" = "specify_cli/core_pack/workflows/bugfix" +"workflows/assess" = "specify_cli/core_pack/workflows/assess" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" "presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync" -# Community bundle catalog snapshot (used for offline discovery) +# Bundle catalog snapshots (used for offline discovery) +"bundles/catalog.json" = "specify_cli/core_pack/bundles/catalog.json" "bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json" [project.optional-dependencies] diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py index ca39a2489b..b855f3a4e7 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundler/services/adapters.py @@ -26,16 +26,22 @@ "https://raw.githubusercontent.com/github/spec-kit/main/" "bundles/catalog.community.json" ) +FIRSTPARTY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "bundles/catalog.json" +) -# The default catalog is reserved for first-party bundles. The community -# catalog is loaded from the repository online and from the packaged snapshot -# offline so discovery remains useful without network access. -_BUILTIN_CATALOGS: dict[str, dict] = { - "builtin://default": { - "schema_version": "1.0", - "catalog_url": "builtin://default", - "bundles": {}, - }, +# Built-in catalogs are resolved directly by URL. ``builtin://default`` is the +# repository-shipped first-party bundle catalog; ``builtin://community`` is the +# community catalog. Both are fetched from the repository online and fall back +# to the packaged wheel snapshot offline so discovery works without network. +_BUILTIN_REPOSITORY_URLS: dict[str, str] = { + "builtin://default": FIRSTPARTY_CATALOG_URL, + "builtin://community": COMMUNITY_CATALOG_URL, +} +_BUILTIN_PACKAGED_SNAPSHOTS: dict[str, str] = { + "builtin://default": "catalog.json", + "builtin://community": "catalog.community.json", } HTTP_TIMEOUT_SECONDS = 10 @@ -99,15 +105,16 @@ def _validate_remote_url(source_id: str, url: str) -> None: ) -def _load_packaged_community_catalog() -> dict: +def _load_packaged_catalog(filename: str) -> dict: + """Load a packaged bundle catalog snapshot from the wheel or repo root.""" core_pack = _locate_core_pack() path = ( - core_pack / "bundles" / "catalog.community.json" + core_pack / "bundles" / filename if core_pack is not None - else _repo_root() / "bundles" / "catalog.community.json" + else _repo_root() / "bundles" / filename ) if not path.is_file(): - raise BundlerError(f"Bundled community catalog not found: {path}") + raise BundlerError(f"Bundled catalog not found: {path}") return loads_json(path.read_text(encoding="utf-8"), origin=str(path)) @@ -132,14 +139,12 @@ def fetch(source: CatalogSource) -> dict: scheme = parsed.scheme.lower() if scheme == "builtin": - if url == "builtin://community": - if allow_network: - return _http_get_json(source.id, COMMUNITY_CATALOG_URL) - return _load_packaged_community_catalog() - payload = _BUILTIN_CATALOGS.get(url) - if payload is None: + repository_url = _BUILTIN_REPOSITORY_URLS.get(url) + if repository_url is None: raise BundlerError(f"Unknown built-in catalog '{url}'.") - return payload + if allow_network: + return _http_get_json(source.id, repository_url) + return _load_packaged_catalog(_BUILTIN_PACKAGED_SNAPSHOTS[url]) if scheme == "file": path = _file_url_to_path(parsed) diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 165f674a36..b506eac2c1 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -439,6 +439,20 @@ def bundle_install( ) +@bundle_app.command("add") +def bundle_add( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Install a bundle's full component set (alias for install).""" + return bundle_install(bundle_id=bundle_id, integration=integration, offline=offline) + + @bundle_app.command("update") def bundle_update( bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 6db4dab769..f5e1754bd6 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -649,7 +649,11 @@ def test_search_json_offline(project: Path): config = { "schema_version": "1.0", "catalogs": [ - {"id": "c", "url": str(catalog), "priority": 1, + # Priority 0 wins over the built-in first-party catalog so the demo + # entry is resolved from this project catalog, while the offline + # packaged first-party catalog (bugfix / assess) still appears in + # search results alongside it. + {"id": "c", "url": str(catalog), "priority": 0, "install_policy": "install-allowed"} ], } @@ -659,10 +663,11 @@ def test_search_json_offline(project: Path): result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) assert result.exit_code == 0 payload = json.loads(result.output) - assert payload[0]["id"] == "demo" + by_id = {entry["id"]: entry for entry in payload} + assert "demo" in by_id # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). - assert payload[0]["verified"] is True - assert payload[0]["trust"] == "verified" + assert by_id["demo"]["verified"] is True + assert by_id["demo"]["trust"] == "verified" def test_search_text_shows_trust(project: Path): @@ -727,14 +732,19 @@ def geturl(self) -> str: def _make_catalog_config(catalog_path: Path, project: Path) -> None: - """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*.""" + """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. + + Uses priority 0 so the test catalog wins over the built-in first-party + ``builtin://default`` catalog and the command under test does not need to + fetch the repository catalog from the network. + """ config = { "schema_version": "1.0", "catalogs": [ { "id": "test", "url": str(catalog_path), - "priority": 1, + "priority": 0, "install_policy": "install-allowed", } ], diff --git a/tests/contract/test_firstparty_bundle_catalog_consistency.py b/tests/contract/test_firstparty_bundle_catalog_consistency.py new file mode 100644 index 0000000000..4d5b01932e --- /dev/null +++ b/tests/contract/test_firstparty_bundle_catalog_consistency.py @@ -0,0 +1,153 @@ +"""Consistency tests for the first-party bundle catalog and manifests. + +``_validate_catalog_manifest`` enforces that a bundle manifest's ``bundle.id`` +and ``bundle.version`` match the catalog entry that pointed to it. This test +locks that relationship in at the source files so it cannot drift without +failing CI. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).parents[2] + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _extension_version(extension_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "extensions" / extension_id / "extension.yml") + return str(manifest["extension"]["version"]) + + +def _workflow_version(workflow_id: str) -> str: + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + return str(manifest["workflow"]["version"]) + + +def _manifest_component_versions(bundle_id: str) -> dict[tuple[str, str], str]: + """Return {(kind, id): version, ...} for pinned components in a bundle manifest.""" + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + versions: dict[tuple[str, str], str] = {} + for kind in ("extensions", "presets", "steps", "workflows"): + for ref in provides.get(kind, []): + if "version" in ref: + versions[(kind.rstrip("s"), ref["id"])] = str(ref["version"]) + return versions + + +def test_firstparty_catalog_matches_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + meta = manifest["bundle"] + + assert entry["id"] == bundle_id, ( + f"catalog key '{bundle_id}' does not match entry id '{entry['id']}'" + ) + assert meta["id"] == bundle_id, ( + f"manifest id '{meta['id']}' does not match catalog key '{bundle_id}'" + ) + assert entry["version"] == meta["version"], ( + f"catalog version for '{bundle_id}' ({entry['version']}) does not match " + f"manifest version ({meta['version']})" + ) + + +def _workflow_catalog_entry(workflow_id: str) -> dict: + catalog = _read_json(REPO_ROOT / "workflows" / "catalog.json") + entry = catalog["workflows"].get(workflow_id) + assert entry is not None, ( + f"workflow '{workflow_id}' is missing from workflows/catalog.json" + ) + return entry + + +def test_firstparty_workflow_catalog_entries_match_shipped_yamls(): + """workflows/catalog.json entries must match the shipped workflow YAMLs. + + ``specify workflow add`` resolves a catalog entry by fetching its ``url`` + and then comparing the downloaded manifest's ``version`` against the + catalog's — a stale entry version or URL makes the install fail (or worse, + serve an old workflow). Lock id, version, and URL in at the source files so + they cannot drift without failing CI. + """ + for workflow_id in ("speckit", "bugfix", "assess"): + entry = _workflow_catalog_entry(workflow_id) + manifest = _read_yaml(REPO_ROOT / "workflows" / workflow_id / "workflow.yml") + meta = manifest["workflow"] + + assert entry["id"] == workflow_id, ( + f"catalog entry id '{entry['id']}' does not match catalog key " + f"'{workflow_id}'" + ) + assert meta["id"] == workflow_id, ( + f"workflows/{workflow_id}/workflow.yml declares id '{meta['id']}', " + f"expected '{workflow_id}'" + ) + assert entry["version"] == str(meta["version"]), ( + f"catalog version for workflow '{workflow_id}' ({entry['version']}) " + f"does not match the shipped workflow.yml version " + f"({meta['version']})" + ) + assert entry["url"] == ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"workflows/{workflow_id}/workflow.yml" + ), f"catalog URL for workflow '{workflow_id}' does not point at the " + "shipped workflow.yml on the repository default branch" + + +def test_firstparty_manifest_pins_match_shipped_versions(): + for bundle_id in ("bugfix", "assess"): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for ext_ref in provides.get("extensions", []): + expected = _extension_version(ext_ref["id"]) + assert str(ext_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins extension {ext_ref['id']} at " + f"{ext_ref.get('version')}, but extensions/{ext_ref['id']}/extension.yml " + f"ships {expected}" + ) + + for wf_ref in provides.get("workflows", []): + expected = _workflow_version(wf_ref["id"]) + assert str(wf_ref.get("version")) == expected, ( + f"{bundle_id} manifest pins workflow {wf_ref['id']} at " + f"{wf_ref.get('version')}, but workflows/{wf_ref['id']}/workflow.yml " + f"ships {expected}" + ) + + +def test_firstparty_catalog_provides_counts_match_manifests(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + + for bundle_id, entry in catalog["bundles"].items(): + manifest = _read_yaml(REPO_ROOT / "bundles" / bundle_id / "bundle.yml") + provides = manifest.get("provides", {}) + + for kind in ("extensions", "presets", "steps", "workflows"): + expected_count = len(provides.get(kind, [])) + assert entry["provides"][kind] == expected_count, ( + f"catalog entry '{bundle_id}' claims {entry['provides'][kind]} {kind}, " + f"but the manifest lists {expected_count}" + ) + + +def test_firstparty_catalog_entries_are_verified(): + catalog = _read_json(REPO_ROOT / "bundles" / "catalog.json") + for bundle_id, entry in catalog["bundles"].items(): + assert entry.get("verified") is True, ( + f"first-party catalog entry '{bundle_id}' must be marked verified: true" + ) diff --git a/tests/contract/test_wheel_bundled_workflows.py b/tests/contract/test_wheel_bundled_workflows.py new file mode 100644 index 0000000000..1346d9c5d2 --- /dev/null +++ b/tests/contract/test_wheel_bundled_workflows.py @@ -0,0 +1,52 @@ +"""Contract tests: every bundled workflow must ship inside the wheel's core_pack. + +``specify workflow add `` (and the bundler's workflow primitive) resolve a +bundled workflow via ``specify_cli._assets._locate_bundled_workflow``, which +checks the wheel's ``specify_cli/core_pack/workflows//`` directory first. +Any workflow marked ``bundled: true`` in ``workflows/catalog.json`` must +therefore be force-included at build time; otherwise the released wheel +advertises a bundled workflow it does not actually ship. +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[2] + + +def _force_include() -> dict[str, str]: + with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"] + + +def _bundled_workflow_ids() -> list[str]: + catalog = json.loads((REPO_ROOT / "workflows" / "catalog.json").read_text()) + return sorted( + workflow_id + for workflow_id, entry in catalog["workflows"].items() + if entry.get("bundled") + ) + + +def test_every_bundled_workflow_is_force_included(): + force_include = _force_include() + bundled = _bundled_workflow_ids() + + assert bundled, "expected at least one bundled workflow in workflows/catalog.json" + for workflow_id in bundled: + assert force_include.get(f"workflows/{workflow_id}") == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ), f"bundled workflow '{workflow_id}' is missing from the wheel force-include list" + + +def test_stock_bundled_workflows_are_force_included(): + # Explicit regression guard for the first-party workflows shipped in #4495. + force_include = _force_include() + for workflow_id in ("speckit", "bugfix", "assess"): + assert force_include[f"workflows/{workflow_id}"] == ( + f"specify_cli/core_pack/workflows/{workflow_id}" + ) diff --git a/tests/integration/test_bundler_offline.py b/tests/integration/test_bundler_offline.py index 8cbc7af9cc..2297dbe504 100644 --- a/tests/integration/test_bundler_offline.py +++ b/tests/integration/test_bundler_offline.py @@ -24,11 +24,21 @@ def _src(source_id, url, priority=1, policy="install-allowed"): ) -def test_builtin_catalog_resolves_offline(): +def test_builtin_default_catalog_resolves_first_party_bundles_offline(): fetcher = make_catalog_fetcher(allow_network=False) stack = CatalogStack([_src("default", "builtin://default")], fetcher) - # Built-in default ships empty; search works without network and returns []. - assert stack.search() == [] + # Built-in default now ships the first-party bundles bugfix and assess. + results = {r.entry.id: r for r in stack.search()} + assert set(results) == {"bugfix", "assess"} + assert all(r.source.id == "default" and r.install_allowed for r in results.values()) + + resolved = stack.resolve("bugfix") + assert resolved.entry.id == "bugfix" + assert resolved.install_allowed is True + + resolved = stack.resolve("assess") + assert resolved.entry.id == "assess" + assert resolved.install_allowed is True def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline(): diff --git a/tests/unit/test_bundler_adapters.py b/tests/unit/test_bundler_adapters.py index 854e60df3f..cc813893b4 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/unit/test_bundler_adapters.py @@ -116,6 +116,46 @@ def test_local_catalog_decode_errors_are_wrapped(tmp_path, use_file_url): fetcher(_source(url)) +def test_builtin_default_catalog_fetches_repository_catalog_online(monkeypatch): + captured: dict = {} + + def fake_http_get_json(source_id, url): + captured["source_id"] = source_id + captured["url"] = url + return {"schema_version": "1.0", "bundles": {}} + + monkeypatch.setattr(adapters, "_http_get_json", fake_http_get_json) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + result = fetcher(_source("builtin://default")) + + assert result["bundles"] == {} + assert captured == { + "source_id": "team", + "url": adapters.FIRSTPARTY_CATALOG_URL, + } + + +def test_builtin_default_catalog_uses_core_pack_snapshot_offline(monkeypatch, tmp_path): + catalog_path = tmp_path / "bundles" / "catalog.json" + catalog_path.parent.mkdir() + catalog_path.write_text( + '{"schema_version":"1.0","bundles":{"packaged":{' + '"id":"packaged","name":"Packaged","version":"1.0.0",' + '"role":"developer","description":"Packaged catalog entry.",' + '"author":"Spec Kit","license":"MIT","download_url":"",' + '"requires":{"speckit_version":">=0.1.0"},' + '"provides":{},"verified":false}}}', + encoding="utf-8", + ) + monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path) + + fetcher = adapters.make_catalog_fetcher(allow_network=False) + result = fetcher(_source("builtin://default")) + + assert "packaged" in result["bundles"] + + def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch): captured: dict = {} diff --git a/tests/workflows/test_bundled_bugfix_assess_workflows.py b/tests/workflows/test_bundled_bugfix_assess_workflows.py new file mode 100644 index 0000000000..ea1df7c444 --- /dev/null +++ b/tests/workflows/test_bundled_bugfix_assess_workflows.py @@ -0,0 +1,60 @@ +"""Guards for the bundled bugfix and assess workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specify_cli._assets import _locate_bundled_workflow +from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + +def _load_workflow(workflow_id: str) -> WorkflowDefinition: + directory = _locate_bundled_workflow(workflow_id) + assert directory is not None, f"bundled workflow '{workflow_id}' not found" + return WorkflowDefinition.from_yaml(directory / "workflow.yml") + + +@pytest.mark.parametrize("workflow_id", ["bugfix", "assess"]) +def test_bundled_workflow_validates_cleanly(workflow_id: str) -> None: + definition = _load_workflow(workflow_id) + assert validate_workflow(definition) == [] + + +def test_bugfix_workflow_has_expected_steps() -> None: + definition = _load_workflow("bugfix") + step_ids = [step["id"] for step in definition.steps] + assert step_ids == ["assess", "review-assessment", "fix", "test"] + + gate = definition.steps[1] + assert gate.get("type") == "gate" + assert gate.get("options") == ["approve", "reject"] + assert gate.get("on_reject") == "abort" + + +def test_assess_workflow_has_expected_steps() -> None: + definition = _load_workflow("assess") + step_ids = [step["id"] for step in definition.steps] + assert step_ids == [ + "intake", + "research", + "define", + "shape", + "decide", + "review-verdict", + ] + + final_gate = definition.steps[-1] + assert final_gate.get("type") == "gate" + assert final_gate.get("options") == ["approve", "reject"] + assert final_gate.get("on_reject") == "abort" + + +@pytest.mark.parametrize("workflow_id", ["bugfix", "assess"]) +def test_bundled_workflow_has_required_inputs(workflow_id: str) -> None: + definition = _load_workflow(workflow_id) + assert "report" in definition.inputs or "idea" in definition.inputs + assert "slug" in definition.inputs + assert definition.inputs["slug"].get("required") is True + assert definition.inputs.get("integration", {}).get("default") == "auto" diff --git a/workflows/README.md b/workflows/README.md index 2c1a9f2bb7..7382d6e625 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -539,6 +539,10 @@ workflows/ ├── README.md # This file ├── catalog.json # Official workflow catalog ├── catalog.community.json # Community workflow catalog -└── speckit/ # Built-in SDD cycle workflow +├── speckit/ # Built-in SDD cycle workflow +│ └── workflow.yml +├── bugfix/ # Built-in bug-fixing pipeline +│ └── workflow.yml +└── assess/ # Built-in idea-assessment pipeline └── workflow.yml ``` diff --git a/workflows/assess/workflow.yml b/workflows/assess/workflow.yml new file mode 100644 index 0000000000..1cab4d5cbf --- /dev/null +++ b/workflows/assess/workflow.yml @@ -0,0 +1,62 @@ +schema_version: "1.0" +workflow: + id: "assess" + name: "Idea Assessment Pipeline" + version: "1.0.0" + author: "GitHub" + description: "Runs intake → research → define → shape → decide with a verdict review gate before a manual /speckit.specify handoff" + +requires: + # Matches the composed assess extension's requirement (extensions/assess/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + idea: + type: string + required: true + prompt: "Raw idea (pasted text, URL, ticket, or codebase pointer)" + slug: + type: string + required: true + prompt: "Assessment slug (short kebab-case name, e.g. offline-mode)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: intake + command: speckit.assess.intake + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.idea }} slug={{ inputs.slug }}" + + - id: research + command: speckit.assess.research + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: define + command: speckit.assess.define + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: shape + command: speckit.assess.shape + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: decide + command: speckit.assess.decide + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: review-verdict + type: gate + message: "Review the assessment verdict. A go verdict is handed off manually with the specify command; this workflow stops here." + options: [approve, reject] + on_reject: abort diff --git a/workflows/bugfix/workflow.yml b/workflows/bugfix/workflow.yml new file mode 100644 index 0000000000..a914eeafa9 --- /dev/null +++ b/workflows/bugfix/workflow.yml @@ -0,0 +1,50 @@ +schema_version: "1.0" +workflow: + id: "bugfix" + name: "Guided Bug Fix" + version: "1.0.0" + author: "GitHub" + description: "Runs bug assess → human review gate → bug fix → bug test on a bug report" + +requires: + # Matches the composed bug extension's requirement (extensions/bug/extension.yml). + speckit_version: ">=0.9.0" + +inputs: + report: + type: string + required: true + prompt: "Bug report (pasted text or URL)" + slug: + type: string + required: true + prompt: "Bug slug (short kebab-case name, e.g. login-timeout)" + integration: + type: string + default: "auto" + prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)" + +steps: + - id: assess + command: speckit.bug.assess + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.report }} slug={{ inputs.slug }}" + + - id: review-assessment + type: gate + message: "Review the assessment before any code change." + options: [approve, reject] + on_reject: abort + + - id: fix + command: speckit.bug.fix + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" + + - id: test + command: speckit.bug.test + integration: "{{ inputs.integration }}" + input: + args: "slug={{ inputs.slug }}" diff --git a/workflows/catalog.json b/workflows/catalog.json index c26a3230a1..d55754a819 100644 --- a/workflows/catalog.json +++ b/workflows/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-09-03T00:00:00Z", + "updated_at": "2026-09-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", "workflows": { "speckit": { @@ -10,7 +10,28 @@ "author": "GitHub", "version": "1.0.1", "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", - "tags": ["sdd", "full-cycle"] + "tags": ["sdd", "full-cycle"], + "bundled": true + }, + "bugfix": { + "id": "bugfix", + "name": "Guided Bug Fix", + "description": "Runs bug assess → human review gate → bug fix → bug test on a bug report", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/bugfix/workflow.yml", + "tags": ["bug", "triage", "workflow", "qa"], + "bundled": true + }, + "assess": { + "id": "assess", + "name": "Idea Assessment Pipeline", + "description": "Runs intake → research → define → shape → decide with a verdict review gate before a manual specify handoff", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/assess/workflow.yml", + "tags": ["assessment", "discovery", "triage", "product"], + "bundled": true } } } From 8a65f54292575baf23aba8698847bb14517536e9 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 10 Sep 2026 07:26:09 +0200 Subject: [PATCH 2/5] fix(bundles): install bundled workflows offline Install bundled workflow components from their packaged workflow.yml instead of falling through to the network-backed workflow catalog. Validate the bundled workflow ID and the bundle's pinned version before delegating to the local workflow install path. Add coverage for offline first-party bundle installs, bundle add alias execution, version pin rejection, and command/input workflow contracts. Assisted-by: opencode (model: glm-5.3, autonomous) --- docs/reference/bundles.md | 2 +- .../bundler/services/primitives.py | 40 ++++++++--- tests/contract/test_bundle_cli.py | 68 ++++++++++++++++++- tests/unit/test_bundler_primitives.py | 9 ++- .../test_bundled_bugfix_assess_workflows.py | 53 ++++++++++++--- 5 files changed, 150 insertions(+), 22 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 6223433888..998ef8646b 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -20,7 +20,7 @@ specify bundle install bugfix specify bundle add assess ``` -The first-party catalog is fetched from the repository online and falls back to the packaged wheel snapshot offline so discovery works without network access. The bundle manifest itself still resolves from its `download_url`, so installing a first-party bundle requires network today; fully offline bundle installation is tracked as follow-up work. +The first-party catalog is fetched from the repository online and falls back to the packaged wheel snapshot offline so discovery works without network access. A local bundle manifest can install bundled extensions and workflows with `--offline`. Catalog-discovered bundle manifests still resolve from their `download_url`, so `specify bundle add ` requires network today; fully offline catalog installation is tracked as follow-up work. ## Search Available Bundles diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index 68a9c47632..29fce13d9b 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -327,7 +327,38 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: - if not self._allow_network and not self._is_bundled(component.id): + from ..._assets import _locate_bundled_workflow + + bundled = _locate_bundled_workflow(component.id) + if bundled is not None: + workflow_file = bundled / "workflow.yml" + try: + from ...workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(workflow_file) + except (OSError, ValueError) as exc: + raise BundlerError( + f"Failed to load bundled workflow '{component.id}': {exc}" + ) from exc + if definition.id != component.id: + raise BundlerError( + f"Bundled workflow at {workflow_file} declares ID " + f"'{definition.id}', expected '{component.id}'." + ) + _assert_pinned_version( + "Workflow", component.id, component.version, definition.version + ) + from ... import workflow_add + + with _chdir(self._root): + _delegate_command( + "install", + f"workflow '{component.id}'", + lambda: workflow_add(str(workflow_file), dev=True, from_url=None), + ) + return + + if not self._allow_network: raise BundlerError( f"Workflow '{component.id}' installs from a catalog and network " f"access is disabled; re-run without --offline or install it first " @@ -361,13 +392,6 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: "Workflow", component.id, component.version, info.get("version") ) - @staticmethod - def _is_bundled(workflow_id: str) -> bool: - # A workflow that ships with Spec Kit installs fully offline. - from ..._assets import _locate_bundled_workflow - - return _locate_bundled_workflow(workflow_id) is not None - def remove(self, component: ComponentRef) -> None: from ... import workflow_remove diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index f5e1754bd6..d9d176e29f 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -25,6 +25,7 @@ ) runner = CliRunner() +REPO_ROOT = Path(__file__).parents[2] MARKUP_BUNDLE_ID = "[red]markup-id[/red]" MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" @@ -73,8 +74,8 @@ def project(tmp_path: Path, monkeypatch) -> Path: def test_bundle_help_lists_all_commands(): result = runner.invoke(app, ["bundle", "--help"]) assert result.exit_code == 0 - for cmd in ("search", "info", "list", "install", "update", "remove", - "validate", "build", "init", "catalog"): + for cmd in ("search", "info", "list", "install", "add", "update", "remove", + "validate", "build", "init", "catalog"): assert cmd in result.output @@ -397,6 +398,69 @@ def _mock_manifest_download(monkeypatch, source_path: Path) -> None: ) +def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: + return valid_manifest_dict( + provides={"workflows": [{"id": workflow_id, "version": version}]} + ) + + +@pytest.mark.parametrize( + ("command", "bundle_id", "extension_id"), + [("install", "bugfix", "bug"), ("add", "assess", "assess")], +) +def test_local_firstparty_bundle_installs_bundled_components_offline( + project: Path, command: str, bundle_id: str, extension_id: str +): + bundle_dir = REPO_ROOT / "bundles" / bundle_id + + result = runner.invoke( + app, ["bundle", command, str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 0, result.output + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + + +def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): + bundle_dir = project / "mismatched-workflow-pin" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "pinned to version 9.9.9" in result.output + assert not (project / ".specify" / "workflows" / "bugfix").exists() + + +def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): + bundle_dir = project / "unbundled-workflow" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" + ) + + result = runner.invoke( + app, ["bundle", "install", str(bundle_dir), "--offline"] + ) + + assert result.exit_code == 1 + assert "network access is disabled" in " ".join(result.output.lower().split()) + + def test_info_expands_full_component_set(project: Path, monkeypatch): bundle_dir = project / "src-bundle" bundle_dir.mkdir() diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index a0fcb3c635..4fa515c1b8 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -75,8 +75,13 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): import specify_cli import specify_cli._assets as assets + bundled = tmp_path / "wf" + bundled.mkdir() + (bundled / "workflow.yml").write_text( + "workflow:\n id: bundled-wf\n version: 1.0.0\n", encoding="utf-8" + ) monkeypatch.setattr( - assets, "_locate_bundled_workflow", lambda wid: tmp_path / "wf" + assets, "_locate_bundled_workflow", lambda wid: bundled ) calls: list[tuple] = [] monkeypatch.setattr( @@ -88,7 +93,7 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): manager = primitive_manager("workflows", tmp_path, allow_network=False) manager.install(_component("workflows", "bundled-wf")) - assert calls == [("bundled-wf", False, None)] + assert calls == [(str(bundled / "workflow.yml"), True, None)] def test_assert_pinned_version_matches_passes(): diff --git a/tests/workflows/test_bundled_bugfix_assess_workflows.py b/tests/workflows/test_bundled_bugfix_assess_workflows.py index ea1df7c444..d38689b29c 100644 --- a/tests/workflows/test_bundled_bugfix_assess_workflows.py +++ b/tests/workflows/test_bundled_bugfix_assess_workflows.py @@ -24,8 +24,25 @@ def test_bundled_workflow_validates_cleanly(workflow_id: str) -> None: def test_bugfix_workflow_has_expected_steps() -> None: definition = _load_workflow("bugfix") - step_ids = [step["id"] for step in definition.steps] - assert step_ids == ["assess", "review-assessment", "fix", "test"] + assert [step["id"] for step in definition.steps] == [ + "assess", + "review-assessment", + "fix", + "test", + ] + + expected_commands = { + "assess": ("speckit.bug.assess", "{{ inputs.report }} slug={{ inputs.slug }}"), + "fix": ("speckit.bug.fix", "slug={{ inputs.slug }}"), + "test": ("speckit.bug.test", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args gate = definition.steps[1] assert gate.get("type") == "gate" @@ -35,8 +52,7 @@ def test_bugfix_workflow_has_expected_steps() -> None: def test_assess_workflow_has_expected_steps() -> None: definition = _load_workflow("assess") - step_ids = [step["id"] for step in definition.steps] - assert step_ids == [ + assert [step["id"] for step in definition.steps] == [ "intake", "research", "define", @@ -45,16 +61,35 @@ def test_assess_workflow_has_expected_steps() -> None: "review-verdict", ] + expected_commands = { + "intake": ("speckit.assess.intake", "{{ inputs.idea }} slug={{ inputs.slug }}"), + "research": ("speckit.assess.research", "slug={{ inputs.slug }}"), + "define": ("speckit.assess.define", "slug={{ inputs.slug }}"), + "shape": ("speckit.assess.shape", "slug={{ inputs.slug }}"), + "decide": ("speckit.assess.decide", "slug={{ inputs.slug }}"), + } + for step in definition.steps: + if step["id"] not in expected_commands: + continue + command, args = expected_commands[step["id"]] + assert step["command"] == command + assert step["integration"] == "{{ inputs.integration }}" + assert step["input"]["args"] == args + final_gate = definition.steps[-1] assert final_gate.get("type") == "gate" assert final_gate.get("options") == ["approve", "reject"] assert final_gate.get("on_reject") == "abort" -@pytest.mark.parametrize("workflow_id", ["bugfix", "assess"]) -def test_bundled_workflow_has_required_inputs(workflow_id: str) -> None: +@pytest.mark.parametrize( + ("workflow_id", "required_inputs"), + [("bugfix", ("report", "slug")), ("assess", ("idea", "slug"))], +) +def test_bundled_workflow_has_required_inputs( + workflow_id: str, required_inputs: tuple[str, ...] +) -> None: definition = _load_workflow(workflow_id) - assert "report" in definition.inputs or "idea" in definition.inputs - assert "slug" in definition.inputs - assert definition.inputs["slug"].get("required") is True + for input_id in required_inputs: + assert definition.inputs[input_id].get("required") is True assert definition.inputs.get("integration", {}).get("default") == "auto" From 9164b1cd82c7f6b0ebcc01b4af4f4aeff6cb759a Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 10 Sep 2026 17:44:16 +0200 Subject: [PATCH 3/5] fix(bundles): address review findings for first-party bundles - Correct README wording for bundle remove behavior (FR-022). - Parenthesize full assertion message in first-party catalog consistency test. - Add contract coverage for `bundle add ` from an empty directory, mocking first-party catalog/manifest HTTP responses and verifying fresh-project init plus bundled extension + workflow installation. Assisted-by: opencode (model: kimi-k2.7-code, supervised) --- bundles/assess/README.md | 2 +- bundles/bugfix/README.md | 2 +- tests/contract/test_bundle_cli.py | 65 +++++++++++++++++++ ...t_firstparty_bundle_catalog_consistency.py | 6 +- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/bundles/assess/README.md b/bundles/assess/README.md index 85aedf6e86..6f1851b2ec 100644 --- a/bundles/assess/README.md +++ b/bundles/assess/README.md @@ -35,4 +35,4 @@ You will be prompted for an idea and a slug. The slug is used as the working dir specify bundle remove assess ``` -Removing the bundle uninstalls both the workflow and the extension only if no other installed bundle still depends on them (FR-022). +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/bundles/bugfix/README.md b/bundles/bugfix/README.md index 2f7805591a..17723b9386 100644 --- a/bundles/bugfix/README.md +++ b/bundles/bugfix/README.md @@ -33,4 +33,4 @@ You will be prompted for a bug report and a slug. The slug is used as the workin specify bundle remove bugfix ``` -Removing the bundle uninstalls both the workflow and the extension only if no other installed bundle still depends on them (FR-022). +Removing the bundle uninstalls the workflow and the extension it contributed, unless they are still depended on by another installed bundle (FR-022). Components you installed independently are not attributed to this bundle and survive removal. diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index d9d176e29f..221c1a9908 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -16,6 +16,7 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundler.services.adapters import FIRSTPARTY_CATALOG_URL from specify_cli.bundler.services.packager import build_bundle from tests.conftest import strip_ansi from tests.bundler_helpers import ( @@ -430,6 +431,70 @@ def test_local_firstparty_bundle_installs_bundled_components_offline( assert registry["workflows"][bundle_id]["version"] == "1.0.0" +@pytest.mark.parametrize( + ("bundle_id", "extension_id"), + [("bugfix", "bug"), ("assess", "assess")], +) +def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( + tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str +): + """``bundle add `` from an empty directory resolves ``builtin://default``. + + The command fetches the first-party catalog and bundle manifest over the + network (both mocked here), initializes a new Spec Kit project, and installs + the bundled extension and workflow without further network access. + """ + project = tmp_path / "fresh" + project.mkdir() + monkeypatch.chdir(project) + + catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() + manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() + expected_manifest_url = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"bundles/{bundle_id}/bundle.yml" + ) + captured_urls: list[str] = [] + + def fake_open_url( + url: str, + timeout: int | None = None, + extra_headers: dict[str, str] | None = None, + redirect_validator=None, + ): + captured_urls.append(url) + if url == FIRSTPARTY_CATALOG_URL: + return FakeBundleResponse(catalog_bytes, url=url) + if url == expected_manifest_url: + return FakeBundleResponse(manifest_bytes, url=url) + raise AssertionError( + f"Unexpected network request in by-ID bundle test: {url}" + ) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke( + app, ["bundle", "add", bundle_id, "--integration", "copilot"] + ) + + assert result.exit_code == 0, result.output + assert "No Spec Kit project here" in result.output + assert (project / ".specify").is_dir() + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert ( + project / ".specify" / "workflows" / bundle_id / "workflow.yml" + ).is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + assert FIRSTPARTY_CATALOG_URL in captured_urls + assert expected_manifest_url in captured_urls + + def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): bundle_dir = project / "mismatched-workflow-pin" (bundle_dir / "bundle.yml").parent.mkdir() diff --git a/tests/contract/test_firstparty_bundle_catalog_consistency.py b/tests/contract/test_firstparty_bundle_catalog_consistency.py index 4d5b01932e..5a7737ab55 100644 --- a/tests/contract/test_firstparty_bundle_catalog_consistency.py +++ b/tests/contract/test_firstparty_bundle_catalog_consistency.py @@ -104,8 +104,10 @@ def test_firstparty_workflow_catalog_entries_match_shipped_yamls(): assert entry["url"] == ( "https://raw.githubusercontent.com/github/spec-kit/main/" f"workflows/{workflow_id}/workflow.yml" - ), f"catalog URL for workflow '{workflow_id}' does not point at the " - "shipped workflow.yml on the repository default branch" + ), ( + f"catalog URL for workflow '{workflow_id}' does not point at the " + "shipped workflow.yml on the repository default branch" + ) def test_firstparty_manifest_pins_match_shipped_versions(): From 6b306c592ae82415d56c42b39427e73d7ebdffb3 Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 11 Sep 2026 11:38:33 +0200 Subject: [PATCH 4/5] docs(bundles): show required workflow inputs in bundle README run commands The run examples showed bare `specify workflow run bugfix|assess`, which fails immediately because both workflows declare required inputs (`report`/`idea` and `slug`). Show the repeatable --input flags with the same example values as the corresponding extension READMEs, and note that inputs omitted from the command line are prompted interactively. Assisted-by: opencode (model: glm-5.2, autonomous) --- bundles/assess/README.md | 6 ++++-- bundles/bugfix/README.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bundles/assess/README.md b/bundles/assess/README.md index 6f1851b2ec..e139af5754 100644 --- a/bundles/assess/README.md +++ b/bundles/assess/README.md @@ -24,10 +24,12 @@ specify bundle add assess ## Run the workflow ```bash -specify workflow run assess +specify workflow run assess \ + --input idea="Let users work offline and sync when they reconnect" \ + --input slug="offline-mode" ``` -You will be prompted for an idea and a slug. The slug is used as the working directory under `.specify/assessments//` for all artifacts. +Inputs omitted from the command line are prompted interactively. The slug is used as the working directory under `.specify/assessments//` for all artifacts. ## Remove diff --git a/bundles/bugfix/README.md b/bundles/bugfix/README.md index 17723b9386..6116cff36a 100644 --- a/bundles/bugfix/README.md +++ b/bundles/bugfix/README.md @@ -22,10 +22,12 @@ specify bundle add bugfix ## Run the workflow ```bash -specify workflow run bugfix +specify workflow run bugfix \ + --input report="https://github.com/example/repo/issues/1234" \ + --input slug="callback-token" ``` -You will be prompted for a bug report and a slug. The slug is used as the working directory under `.specify/bugs//` for all artifacts. +Inputs omitted from the command line are prompted interactively. The slug is used as the working directory under `.specify/bugs//` for all artifacts. ## Remove From 37a7213df2041d5106a6f4de1fc7bd19208db3bb Mon Sep 17 00:00:00 2001 From: Markus Date: Fri, 11 Sep 2026 11:38:37 +0200 Subject: [PATCH 5/5] test(events): pin the PowerShell launcher in the ps-variant argv test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_ps_variant_prefixed_with_powershell_launcher asserted that a real pwsh/powershell launcher resolves on PATH, so it failed on machines with neither installed (a pre-existing, environment-specific failure since #4340 removed the fake "pwsh" fallback — not related to the bundles work, but it blocked a clean local full-suite run). The test's contract is argv construction (launcher, -File, script), not launcher discovery, so monkeypatch shutil.which to a synthetic pwsh path — the same pattern the no-launcher sibling test already uses — keeping the test running (not skipped) on every platform. Assisted-by: opencode (model: glm-5.2, autonomous) --- tests/integrations/test_events.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index f5159c1d41..4302602ab6 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1328,10 +1328,11 @@ def failing_read_text(self_path, *args, **kwargs): argv = _resolve_event_command_argv(template, tmp_path, None) assert argv is None - def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): + def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path, monkeypatch): """S6: the ps variant prefixes argv with pwsh/powershell -File so subprocess.run(shell=False) can execute the .ps1 script.""" from specify_cli.events import _resolve_event_command_argv + import shutil as _shutil cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) @@ -1347,6 +1348,13 @@ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): ps_dir.mkdir(parents=True) (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") + # The argv contract under test does not depend on a real PowerShell + # install; pin the launcher (mirroring the no-launcher sibling below) + # so the test runs on platforms without pwsh/powershell on PATH. + monkeypatch.setattr( + _shutil, "which", lambda name: "/usr/bin/pwsh" if name == "pwsh" else None + ) + argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # Launcher (pwsh or powershell), -File, then the .specify-anchored