feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803
feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803mnriem wants to merge 16 commits intogithub:mainfrom
Conversation
…github#1752) Bundle templates, commands, and scripts inside the specify-cli wheel so that `specify init` works without any network access by default. Changes: - pyproject.toml: add hatchling force-include for core_pack assets; bump version to 0.2.1 - __init__.py: add _locate_core_pack(), _generate_agent_commands() (Python port of generate_commands() shell function), and scaffold_from_core_pack(); modify init() to scaffold from bundled assets by default; add --from-github flag to opt back in to the GitHub download path - release.yml: build wheel during CI release job - create-github-release.sh: attach .whl as a release asset - docs/installation.md: add Enterprise/Air-Gapped Installation section - README.md: add Option 3 enterprise install with accurate offline story Closes github#1711 Addresses github#1752
There was a problem hiding this comment.
Pull request overview
This PR makes specify init work offline by bundling the core template pack (templates/commands/scripts) inside the specify-cli wheel, and updates the release workflow/docs to support air-gapped installation via a published .whl release asset.
Changes:
- Bundle core templates/commands/scripts into the wheel and scaffold from those assets by default (with
--from-githubto force network download). - Add runtime generation of agent-specific command files (md/toml/agent.md + Copilot prompt companions).
- Publish the wheel as a GitHub release asset and document enterprise/air-gapped install steps.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds core-pack discovery, offline scaffolding, and agent command generation; updates init to be offline-first with --from-github. |
pyproject.toml |
Bumps version and force-includes core assets into the wheel. |
docs/installation.md |
Adds enterprise/air-gapped installation instructions and offline init guidance. |
README.md |
Documents the new air-gapped installation option via wheel. |
CHANGELOG.md |
Notes offline-first init, --from-github, and wheel release asset. |
.github/workflows/scripts/create-github-release.sh |
Attaches the built wheel to GitHub releases. |
.github/workflows/release.yml |
Adds a wheel build step to the release workflow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-1752 # Conflicts: # CHANGELOG.md # pyproject.toml # src/specify_cli/__init__.py
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:1016
- The
_generate_agent_commands()docstring states TOML output is for "Gemini/Qwen/Tabnine", but Qwen is configured/packaged as Markdown commands (not TOML). Please update the docstring to match the actual supported formats so it stays consistent withcreate-release-packages.shand the existing tests.
"""Generate agent-specific command files from Markdown command templates.
Python equivalent of the generate_commands() shell function in
.github/workflows/scripts/create-release-packages.sh. Handles Markdown,
TOML (Gemini/Qwen/Tabnine), and .agent.md (Copilot) output formats.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Embed release scripts (bash + PowerShell) in wheel via pyproject.toml - Replace Python _generate_agent_commands() with subprocess invocation of the canonical create-release-packages.sh, guaranteeing byte-for-byte parity between 'specify init --offline' and GitHub release ZIPs - Fix macOS bash 3.2 compat in release script: replace cp --parents, local -n (nameref), and mapfile with POSIX-safe alternatives - Fix _TOML_AGENTS: remove qwen (uses markdown per release script) - Rename --from-github to --offline (opt-in to bundled assets) - Add _locate_release_script() for cross-platform script discovery - Update tests: remove bash 4+/GNU coreutils requirements, handle Kimi directory-per-skill layout, 576 tests passing - Update CHANGELOG and docs/installation.md
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
src/specify_cli/init.py:1534
- PR description/docs indicate init should be offline-first with an explicit opt-in to GitHub (e.g.
--from-github), but the current docstring/implementation says GitHub is the default and--offlineis opt-in. Please align the CLI flags/defaults with the intended UX (or update the PR/docs accordingly) to avoid confusing air-gapped users.
By default, project files are downloaded from the latest GitHub release.
Use --offline to scaffold from assets bundled inside the specify-cli
package instead (no internet access required, ideal for air-gapped or
enterprise environments).
src/specify_cli/init.py:1526
- The new
--offlineinit path isn't covered by CLI-level tests. Consider adding aCliRunnertest that invokesspecify init ... --offlinewithscaffold_from_core_packmocked and assertsdownload_and_extract_templateis not called (and that failures don't trigger network attempts when offline is requested).
debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"),
github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required)"),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
tests/test_core_pack_scaffold.py:216
- These tests call
scaffold_from_core_pack()separately in many parametrized test cases, and that function spawns the release script each time. With ~N agents this becomes O(N * tests) subprocess invocations and can significantly slow CI. Consider a session-scoped fixture that scaffolds once per agent (per script type) into tmp dirs and reuses the resulting trees across invariant tests.
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_creates_specify_scripts(tmp_path, agent):
"""scaffold_from_core_pack copies at least one script into .specify/scripts/."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'"
scripts_dir = project / ".specify" / "scripts" / "bash"
assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'"
assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'"
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_creates_specify_templates(tmp_path, agent):
"""scaffold_from_core_pack copies at least one page template into .specify/templates/."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok
tpl_dir = project / ".specify" / "templates"
assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'"
assert any(tpl_dir.iterdir()), ".specify/templates/ is empty"
@pytest.mark.parametrize("agent", _TESTABLE_AGENTS)
def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems):
"""Command files land in the directory declared by AGENT_CONFIG."""
project = tmp_path / "proj"
ok = scaffold_from_core_pack(project, agent, "sh")
assert ok
.github/workflows/release.yml:46
- The new wheel build writes into
.genreleases/, butcreate-release-packages.shclears.genreleases/*at the start of its run. With the current step order, the wheel will be deleted before the release is created, socreate-github-release.shwon't be able to attach it. Build the wheel aftercreate-release-packages.sh, or output the wheel to a different directory that isn’t wiped (or adjust the script to not delete unrelated artifacts).
- name: Build Python wheel
if: steps.check_release.outputs.exists == 'false'
run: |
pip install build
python -m build --wheel --outdir .genreleases/
- 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.version.outputs.tag }}
src/specify_cli/init.py:1002
_locate_core_pack()’s docstring claims it falls back to source-checkout/editable install paths, but the implementation only checksPath(__file__).parent / "core_pack"and otherwise returnsNone. Either implement the documented fallback behavior here (e.g., detect repo-root assets) or update the docstring so callers don’t rely on behavior that doesn’t exist.
def _locate_core_pack() -> Path | None:
"""Return the filesystem path to the bundled core_pack directory.
Works for wheel installs (hatchling force-include puts the directory next to
__init__.py as specify_cli/core_pack/) and for source-checkout / editable
installs (falls back to the repo-root templates/ and scripts/ trees).
Returns None only when neither location exists.
"""
# Wheel install: core_pack is a sibling directory of this file
candidate = Path(__file__).parent / "core_pack"
if candidate.is_dir():
return candidate
return None
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… network - _locate_core_pack() docstring now accurately describes that it only finds wheel-bundled core_pack/; source-checkout fallback lives in callers - init() --offline + no bundled assets now exits with a clear error (previously printed a warning and silently fell back to GitHub download) - init() scaffold failure under --offline now exits with an error instead of retrying via download_and_extract_template Addresses reviewer comment: github#1803
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (1)
tests/test_core_pack_scaffold.py:503
- This fixture runs the release script for every agent and extracts ZIPs, but it does not clean up the generated
.genreleases/spec-kit-template-*.zipartifacts in the repo. Please add teardown cleanup (e.g., delete the specific ZIPs or the.genreleasescontents after extraction) so local test runs don’t accumulate large build outputs.
tmp = tmp_path_factory.mktemp("release_script")
extracted: dict[str, Path] = {}
for agent in _TESTABLE_AGENTS:
zip_path = _run_release_script(agent, "sh", tmp, bash)
dest = tmp / f"extracted-{agent}"
dest.mkdir()
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(dest)
extracted[agent] = dest
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(shell): harden validate_subset against glob injection in case patterns - fix(shell): make GENRELEASES_DIR overridable via env var for test isolation - fix(cli): probe pwsh then powershell on Windows instead of hardcoding pwsh - fix(cli): remove unreachable fallback branch when --offline fails - fix(cli): improve --offline error message with common failure causes - fix(release): move wheel build step after create-release-packages.sh - fix(docs): add --offline to installation.md air-gapped example - fix(tests): remove unused genreleases_dir param from _run_release_script - fix(tests): rewrite parity test to run one agent at a time with isolated temp dirs, preventing cross-agent interference from rm -rf
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(shell): replace case-pattern membership with explicit loop + == check for unambiguous glob-safety in validate_subset() - fix(cli): require pwsh (PowerShell 7) only; drop powershell (PS5) fallback since the bundled script uses #requires -Version 7.0 - fix(cli): add bash and zip preflight checks in scaffold_from_core_pack() with clear error messages if either is missing - fix(build): list individual template files in pyproject.toml force-include to avoid duplicating templates/commands/ in the wheel
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add 120s timeout to subprocess.run in scaffold_from_core_pack to prevent indefinite hangs during offline scaffolding - Add test_pyproject_force_include_covers_all_templates to catch missing template files in wheel bundling - Tighten kiro alias test to assert specific scaffold path (download vs offline)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(offline): use handle_vscode_settings() merge for --here --offline to prevent data loss on existing .vscode/settings.json - fix(release): glob wheel filename in create-github-release.sh instead of hardcoding version, preventing upload failures on version mismatch - docs(release): add comment noting pyproject.toml version is synced by release-trigger.yml before the tag is pushed
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- fix(offline): pwsh-only, no powershell.exe fallback; clarify error message - fix(offline): tighten _has_bundled to check scripts dir for source checkouts - feat(release): build specify-bundle-v*.zip with all deps at release time - feat(release): attach offline bundle ZIP to GitHub release assets - docs: simplify air-gapped install to single ZIP download from releases - docs: add Windows PowerShell 7+ (pwsh) requirement note
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
tests/test_core_pack_scaffold.py:276
- Several tests call scaffold_from_core_pack() but never assert it succeeded (or that the expected command directory exists). If scaffolding fails, cmd_dir.rglob(...) yields no files and the test can pass vacuously, masking regressions. Add an explicit
assert okand/orassert cmd_dir.is_dir()before iterating files here (and apply the same pattern to the other invariant tests in this file).
project = tmp_path / "proj"
scaffold_from_core_pack(project, agent, "sh")
cmd_dir = _expected_cmd_dir(project, agent)
for f in cmd_dir.rglob("*"):
if f.is_file():
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, (
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add timeout=300 and returncode check to _run_release_script() to fail fast with clear output on script hangs or failures - Remove unused import specify_cli, _SOURCE_TEMPLATES, bundled_project fixture - Add session-scoped scaffolded_sh/scaffolded_ps fixtures that scaffold once per agent and reuse the output directory across all invariant tests - Reduces test_core_pack_scaffold runtime from ~175s to ~51s (3.4x faster) - Parity tests still scaffold independently for isolation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| else: | ||
| repo_root = Path(__file__).parent.parent.parent | ||
| templates_dir = repo_root / "templates" | ||
|
|
| pip download -d .genreleases/specify-bundle/ .genreleases/specify_cli-*.whl | ||
| cd .genreleases && zip -r specify-bundle-${{ steps.version.outputs.tag }}.zip specify-bundle/ |
|
|
||
| The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub. | ||
|
|
||
| > **Note:** Python 3.11+ is required. All dependencies are pure-Python wheels, so the bundle works on any platform without recompilation. |
Summary
Closes #1711
Addresses #1752
Embeds templates, commands, and scripts inside the
specify-cliPython wheel so thatspecify init --offlineworks with zero network access. A pre-built offline bundle ZIP (wheel + all dependencies) is also published as a GitHub release asset, providing a single-file installation path for enterprise/air-gapped environments.Problem
Two related air-gapped blockers were addressed together:
.whlwas available as a release assetspecify init(feat(cli): Embed core template pack in CLI package for air-gapped deployment #1711) —api.github.comis blocked; the init command unconditionally callsdownload_template_from_github()to fetch a release ZIPA user who solved problem 1 (offline pip install) would immediately hit problem 2 on first use.
Solution
1. Bundle core assets in the wheel (
pyproject.toml)2. New
--offlineflag forspecify initBy default,
specify initdownloads project files from the latest GitHub release (unchanged behavior). The new--offlineflag opts in to using bundled assets instead:If
--offlineis specified but bundled assets cannot be found or scaffolding fails, the CLI errors out with a clear message rather than silently falling back to a network download.3. Offline scaffold via release script (
scaffold_from_core_pack)_locate_core_pack()— finds bundled assets (wheel install) or returns None_locate_release_script()— finds the platform-appropriate release script; on Windows requirespwsh(PowerShell 7+); Windows PowerShell 5.x (powershell.exe) is not supportedscaffold_from_core_pack()— invokes the bundledcreate-release-packages.sh(or.ps1) in a temp directory to generate the exact same output as the GitHub release ZIPs, then copies the result to the project directory--here),.vscode/settings.jsonis merged viahandle_vscode_settings()(JSONC-safe) instead of overwritten4. Offline dependency bundle (
specify-bundle-v*.zip)The release workflow builds a single ZIP containing the specify-cli wheel and all runtime dependencies (~2.5 MB). Air-gapped users download one file, unzip, and install with
pip install --no-index. Nopip downloadstep required on the user's side.5. Wheel published as release asset
release.yml: build step added (python -m build --wheel) — runs aftercreate-release-packages.shso the script'srm -rfdoesn't wipe the wheelcreate-github-release.sh: wheel found via glob (specify_cli-*-py3-none-any.whl) to avoid version mismatch issues; offline bundle ZIP attached conditionally6. Shell script improvements
GENRELEASES_DIRis now overridable via environment variable (defaults to.genreleases), enabling tests to write to temp dirs instead of polluting the repovalidate_subset()hardened against glob injection incasepatterns7. Documentation
docs/installation.md: new "Enterprise / Air-Gapped Installation" section — download one ZIP, unzip, installREADME.md: "Option 3: Enterprise / Air-Gapped Installation" with simplified instructionsAcceptance criteria from #1711
specify init --offlinescaffolds from embedded assets with no network callsspecify init(no--offline) retains current GitHub-download behaviorpip install specify-cliincludes all core templates, commands, and scriptsforce-includein pyproject.toml)create-release-packages.shcontinues to workspecify init --offline)--here --offlinemerges settings.json instead of overwritingpwsh(PowerShell 7+) onlyTesting
Includes parity tests (one per agent) verifying byte-for-byte equivalence between
scaffold_from_core_pack()output and the canonical release script ZIP.